2
0
mirror of https://github.com/offen/website.git synced 2024-11-22 09:00:28 +01:00

add accounts and user CRUD and connect to auth layer

This commit is contained in:
Frederik Ring 2019-07-10 17:17:50 +02:00
parent 670032ee92
commit d5be3feb7e
15 changed files with 323 additions and 64 deletions

View File

@ -196,6 +196,7 @@ jobs:
docker: docker:
- image: circleci/python:3.6 - image: circleci/python:3.6
environment: environment:
POSTGRES_CONNECTION_STRING: postgres://circle:test@localhost:5432/circle_test?sslmode=disable
HASHED_PASSWORD: JDJhJDEwJGpFRXJMOVVSQndZQlFQNjkxallkZi53aGp1cDMvRW5maGUvakZleG1pWFlnWEVXcU93ODBp HASHED_PASSWORD: JDJhJDEwJGpFRXJMOVVSQndZQlFQNjkxallkZi53aGp1cDMvRW5maGUvakZleG1pWFlnWEVXcU93ODBp
JWT_PRIVATE_KEY: |- JWT_PRIVATE_KEY: |-
-----BEGIN PRIVATE KEY----- -----BEGIN PRIVATE KEY-----
@ -236,7 +237,10 @@ jobs:
a3B4L0waKzP5QWcO865n1HCUTnV+s4lNcphBDZCrSwTkXnVnQWVPCL7ssoQyM0u3 a3B4L0waKzP5QWcO865n1HCUTnV+s4lNcphBDZCrSwTkXnVnQWVPCL7ssoQyM0u3
HQIDAQAB HQIDAQAB
-----END PUBLIC KEY----- -----END PUBLIC KEY-----
- image: circleci/postgres:11.2-alpine
environment:
- POSTGRES_USER=circle
- POSTGRES_PASSWORD=test
working_directory: ~/offen/accounts working_directory: ~/offen/accounts
steps: steps:
- checkout: - checkout:
@ -254,6 +258,16 @@ jobs:
paths: paths:
- ~/offen/accounts/venv - ~/offen/accounts/venv
key: offen-accounts-{{ checksum "requirements.txt" }}-{{ checksum "requirements-dev.txt" }} key: offen-accounts-{{ checksum "requirements.txt" }}-{{ checksum "requirements-dev.txt" }}
- run:
name: Waiting for Postgres to be ready
command: |
for i in `seq 1 10`;
do
nc -z localhost 5432 && echo Success && exit 0
echo -n .
sleep 1
done
echo Failed waiting for Postgres && exit 1
- run: - run:
name: Run tests name: Run tests
command: | command: |

View File

@ -20,5 +20,6 @@ setup:
bootstrap: bootstrap:
@docker-compose run kms make bootstrap @docker-compose run kms make bootstrap
@docker-compose run server make bootstrap @docker-compose run server make bootstrap
@docker-compose run accounts make bootstrap
.PHONY: setup bootstrap .PHONY: setup bootstrap

View File

@ -4,4 +4,7 @@ test:
fmt: fmt:
@black . @black .
.PHONY: test fmt bootstrap:
@python -m scripts.bootstrap
.PHONY: test fmt bootstrap

View File

@ -1,5 +1,23 @@
from os import environ
from flask import Flask from flask import Flask
from flask_sqlalchemy import SQLAlchemy
from flask_admin import Admin
app = Flask(__name__) app = Flask(__name__)
app.secret_key = environ.get("SESSION_SECRET")
app.config["SQLALCHEMY_DATABASE_URI"] = environ.get("POSTGRES_CONNECTION_STRING")
db = SQLAlchemy(app)
import accounts.views from accounts.models import Account, User
from accounts.views import AccountView, UserView
import accounts.api
# set optional bootswatch theme
app.config["FLASK_ADMIN_SWATCH"] = "flatly"
admin = Admin(app, name="offen admin", template_mode="bootstrap3")
# Add administrative views here
admin.add_view(AccountView(Account, db.session))
admin.add_view(UserView(User, db.session))

93
accounts/accounts/api.py Normal file
View File

@ -0,0 +1,93 @@
from datetime import datetime, timedelta
from os import environ
import base64
from functools import wraps
from flask import jsonify, make_response, request
from flask_cors import cross_origin
from passlib.hash import bcrypt
import jwt
from accounts import app
from accounts.models import User
def json_error(handler):
@wraps(handler)
def wrapped_handler(*args, **kwargs):
try:
return handler(*args, **kwargs)
except Exception as server_error:
return (
jsonify(
{
"error": "Internal server error: {}".format(str(server_error)),
"status": 500,
}
),
500,
)
return wrapped_handler
class UnauthorizedError(Exception):
pass
@app.route("/api/login", methods=["POST"])
@cross_origin(origins=[environ.get("CORS_ORIGIN", "*")], supports_credentials=True)
@json_error
def post_login():
credentials = request.get_json(force=True)
try:
match = User.query.filter_by(email=credentials["username"]).first()
if not match:
raise UnauthorizedError("bad username")
if not bcrypt.verify(credentials["password"], match.hashed_password):
raise UnauthorizedError("bad password")
except UnauthorizedError as unauthorized_error:
resp = make_response(jsonify({"error": str(unauthorized_error), "status": 401}))
resp.set_cookie("auth", "", expires=0)
resp.status_code = 401
return resp
private_key = environ.get("JWT_PRIVATE_KEY", "")
expiry = datetime.utcnow() + timedelta(hours=24)
encoded = jwt.encode(
{"ok": True, "exp": expiry}, private_key.encode(), algorithm="RS256"
).decode("utf-8")
resp = make_response(jsonify({"match": match}))
resp.set_cookie(
"auth",
encoded,
httponly=True,
expires=expiry,
path="/",
domain=environ.get("COOKIE_DOMAIN"),
samesite="strict",
)
return resp
@app.route("/api/login", methods=["GET"])
@cross_origin(origins=[environ.get("CORS_ORIGIN", "*")], supports_credentials=True)
@json_error
def get_login():
auth_cookie = request.cookies.get("auth")
public_key = environ.get("JWT_PUBLIC_KEY", "")
try:
jwt.decode(auth_cookie, public_key)
except jwt.exceptions.PyJWTError as unauthorized_error:
return jsonify({"error": str(unauthorized_error), "status": 401}), 401
return jsonify({"ok": True})
# This route is not supposed to be called by client-side applications, so
# no CORS configuration is added
@app.route("/api/key", methods=["GET"])
@json_error
def key():
public_key = environ.get("JWT_PUBLIC_KEY", "").strip()
return jsonify({"key": public_key})

View File

@ -0,0 +1,39 @@
from uuid import uuid4
from accounts import db
def generate_key():
return str(uuid4())
class Account(db.Model):
__tablename__ = "accounts"
account_id = db.Column(db.String, primary_key=True, default=generate_key)
name = db.Column(db.String, nullable=False, unique=True)
users = db.relationship("AccountUserAssociation", back_populates="account")
def __repr__(self):
return self.name
class User(db.Model):
__tablename__ = "users"
user_id = db.Column(db.String, primary_key=True, default=generate_key)
email = db.Column(db.String, nullable=False, unique=True)
hashed_password = db.Column(db.String, nullable=False)
accounts = db.relationship("AccountUserAssociation", back_populates="user")
class AccountUserAssociation(db.Model):
__tablename__ = "account_to_user"
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.String, db.ForeignKey("users.user_id"), nullable=False)
account_id = db.Column(
db.String, db.ForeignKey("accounts.account_id"), nullable=False
)
user = db.relationship("User", back_populates="accounts")
account = db.relationship("Account", back_populates="users")

View File

@ -1,69 +1,97 @@
from datetime import datetime, timedelta from datetime import datetime, timedelta
from os import environ from os import environ
import base64
from flask import jsonify, render_template, make_response, request import requests
from flask_cors import cross_origin from flask_admin.contrib.sqla import ModelView
from wtforms import PasswordField, StringField, Form
from wtforms.validators import InputRequired, EqualTo
from passlib.hash import bcrypt from passlib.hash import bcrypt
import jwt import jwt
from accounts import app from accounts import db
from accounts.models import AccountUserAssociation
@app.route("/")
def home():
return render_template("index.html")
@app.route("/api/login", methods=["POST"]) class RemoteServerException(Exception):
@cross_origin(origins=[environ.get("CORS_ORIGIN", "*")], supports_credentials=True) status = 0
def post_login():
credentials = request.get_json(force=True)
if credentials["username"] != environ.get("USER", "offen"):
return jsonify({"error": "bad username", "status": 401}), 401
hashed_password = base64.standard_b64decode(environ.get("HASHED_PASSWORD", ""))
if not bcrypt.verify(credentials["password"], hashed_password):
return jsonify({"error": "bad password", "status": 401}), 401
def create_remote_account(name, account_id):
private_key = environ.get("JWT_PRIVATE_KEY", "") private_key = environ.get("JWT_PRIVATE_KEY", "")
expiry = datetime.utcnow() + timedelta(hours=24) expiry = datetime.utcnow() + timedelta(seconds=10)
try: encoded = jwt.encode(
encoded = jwt.encode( {"ok": True, "exp": expiry, "priv": {"rpc": "1"}},
{"ok": True, "exp": expiry}, private_key.encode(), algorithm="RS256" private_key.encode(),
).decode("utf-8") algorithm="RS256",
except jwt.exceptions.PyJWTError as encode_error: ).decode("utf-8")
return jsonify({"error": str(encode_error), "status": 500}), 500
resp = make_response(jsonify({"ok": True})) r = requests.post(
resp.set_cookie( "{}/accounts".format(environ.get("SERVER_HOST")),
"auth", json={"name": name, "account_id": account_id},
encoded, headers={"X-RPC-Authentication": encoded},
httponly=True,
expires=expiry,
path="/",
domain=environ.get("COOKIE_DOMAIN"),
samesite="strict"
) )
return resp
if r.status_code > 299:
err = r.json()
remote_err = RemoteServerException(err["error"])
remote_err.status = err["status"]
raise remote_err
@app.route("/api/login", methods=["GET"]) class AccountForm(Form):
@cross_origin(origins=[environ.get("CORS_ORIGIN", "*")], supports_credentials=True) name = StringField(
def get_login(): "Account Name",
auth_cookie = request.cookies.get("auth") validators=[InputRequired()],
public_key = environ.get("JWT_PUBLIC_KEY", "") description="This is the account name visible to users",
try: )
jwt.decode(auth_cookie, public_key)
except jwt.exceptions.PyJWTError as unauthorized_error:
return jsonify({"error": str(unauthorized_error), "status": 401}), 401
return jsonify({"ok": True})
# This route is not supposed to be called by client-side applications, so class AccountView(ModelView):
# no CORS configuration is added form = AccountForm
@app.route("/api/key", methods=["GET"]) column_display_all_relations = True
def key(): column_list = ("account_id", "name")
public_key = environ.get("JWT_PUBLIC_KEY", "").strip()
return jsonify({"key": public_key}) def after_model_change(self, form, model, is_created):
if is_created:
try:
create_remote_account(model.name, model.account_id)
except RemoteServerException as server_error:
db.session.delete(model)
db.session.commit()
raise server_error
class UserView(ModelView):
inline_models = [(AccountUserAssociation, dict(form_columns=["id", "account"]))]
column_auto_select_related = True
column_display_all_relations = True
column_list = ("user_id", "email")
form_columns = ("email", "accounts")
def on_model_change(self, form, model, is_created):
if form.password.data:
model.hashed_password = bcrypt.hash(form.password.data)
def get_create_form(self):
form = super().get_create_form()
form.password = PasswordField(
"Password",
validators=[
InputRequired(),
EqualTo("confirm", message="Passwords must match"),
],
)
form.confirm = PasswordField("Repeat Password", validators=[InputRequired()])
return form
def get_edit_form(self):
form = super().get_edit_form()
form.password = PasswordField(
"Password",
description="When left blank, the password will remain unchanged on update",
validators=[
EqualTo("confirm", message="Passwords must match"),
],
)
form.confirm = PasswordField("Repeat Password", validators=[])
return form

0
accounts/bootstrap.yml Executable file
View File

View File

@ -1,2 +1,3 @@
pytest pytest
black black
pyyaml

View File

@ -1,6 +1,10 @@
Flask==1.0.2 Flask==1.0.2
Flask-Admin==1.5.3
Flask-Cors==3.0.8 Flask-Cors==3.0.8
Flask-SQLAlchemy==2.4.0
werkzeug==0.15.4 werkzeug==0.15.4
pyjwt[crypto]==1.7.1 pyjwt[crypto]==1.7.1
passlib==1.7.1 passlib==1.7.1
bcrypt==3.1.7 bcrypt==3.1.7
psycopg2==2.8.3
requests==2.22.0

View File

23
accounts/scripts/bootstrap.py Executable file
View File

@ -0,0 +1,23 @@
import yaml
from passlib.hash import bcrypt
from accounts import db
from accounts.models import Account
if __name__ == "__main__":
db.drop_all()
db.create_all()
with open("./bootstrap.yml", "r") as stream:
data = yaml.safe_load(stream)
for account in data["accounts"]:
record = Account(
name=account["name"],
account_id=account["id"],
)
db.session.add(record)
db.session.commit()
print("Successfully bootstrapped accounts database")

View File

@ -26,6 +26,10 @@ custom:
production: vault.offen.dev production: vault.offen.dev
staging: vault-staging.offen.dev staging: vault-staging.offen.dev
alpha: vault-alpha.offen.dev alpha: vault-alpha.offen.dev
serverHost:
production: server.offen.dev
staging: server-staging.offen.dev
alpha: server-alpha.offen.dev
domain: domain:
production: accounts.offen.dev production: accounts.offen.dev
staging: accounts-staging.offen.dev staging: accounts-staging.offen.dev
@ -60,9 +64,10 @@ functions:
path: '{proxy+}' path: '{proxy+}'
method: any method: any
environment: environment:
USER: offen
CORS_ORIGIN: https://${self:custom.origin.${self:custom.stage}} CORS_ORIGIN: https://${self:custom.origin.${self:custom.stage}}
COOKIE_DOMAIN: ${self:custom.cookieDomain.${self:custom.stage}} COOKIE_DOMAIN: ${self:custom.cookieDomain.${self:custom.stage}}
JWT_PRIVATE_KEY: '${ssm:/aws/reference/secretsmanager/${self:custom.stage}/accounts/jwtPrivateKey~true}' JWT_PRIVATE_KEY: '${ssm:/aws/reference/secretsmanager/${self:custom.stage}/accounts/jwtPrivateKey~true}'
JWT_PUBLIC_KEY: '${ssm:/aws/reference/secretsmanager/${self:custom.stage}/accounts/jwtPublicKey~true}' JWT_PUBLIC_KEY: '${ssm:/aws/reference/secretsmanager/${self:custom.stage}/accounts/jwtPublicKey~true}'
HASHED_PASSWORD: ${ssm:/aws/reference/secretsmanager/${self:custom.stage}/accounts/hashedBasicAuthPassword~true} HASHED_PASSWORD: ${ssm:/aws/reference/secretsmanager/${self:custom.stage}/accounts/hashedBasicAuthPassword~true}
SESSION_SECRET: '${ssm:/aws/reference/secretsmanager/${self:custom.stage}/accounts/sessionSecret~true}'
SERVER_URL: ${self:custom.serverHost.${self:custom.stage}}

View File

@ -24,6 +24,11 @@ services:
environment: environment:
POSTGRES_PASSWORD: develop POSTGRES_PASSWORD: develop
accounts_database:
image: postgres:11.2
environment:
POSTGRES_PASSWORD: develop
server: server:
build: build:
context: '.' context: '.'
@ -31,6 +36,7 @@ services:
working_dir: /offen/server working_dir: /offen/server
volumes: volumes:
- .:/offen - .:/offen
- ./bootstrap.yml:/offen/server/bootstrap.yml
- serverdeps:/go/pkg/mod - serverdeps:/go/pkg/mod
environment: environment:
POSTGRES_CONNECTION_STRING: postgres://postgres:develop@server_database:5432/postgres?sslmode=disable POSTGRES_CONNECTION_STRING: postgres://postgres:develop@server_database:5432/postgres?sslmode=disable
@ -100,16 +106,20 @@ services:
working_dir: /offen/accounts working_dir: /offen/accounts
volumes: volumes:
- .:/offen - .:/offen
- ./bootstrap.yml:/offen/accounts/bootstrap.yml
- accountdeps:/root/.local - accountdeps:/root/.local
command: flask run --host 0.0.0.0 command: flask run --host 0.0.0.0
ports: ports:
- 5000:5000 - 5000:5000
links:
- accounts_database
environment: environment:
FLASK_APP: accounts FLASK_APP: accounts:app
FLASK_ENV: development FLASK_ENV: development
POSTGRES_CONNECTION_STRING: postgres://postgres:develop@accounts_database:5432/postgres?sslmode=disable
CORS_ORIGIN: http://localhost:9977 CORS_ORIGIN: http://localhost:9977
# local password is `develop` SERVER_HOST: http://server:8080
HASHED_PASSWORD: JDJhJDEwJGpFRXJMOVVSQndZQlFQNjkxallkZi53aGp1cDMvRW5maGUvakZleG1pWFlnWEVXcU93ODBp SESSION_SECRET: vndJRFJTiyjfgtTF
JWT_PRIVATE_KEY: |- JWT_PRIVATE_KEY: |-
-----BEGIN PRIVATE KEY----- -----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCa6AEl0RUW43YS MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCa6AEl0RUW43YS

View File

@ -26,9 +26,18 @@ const ClaimsContextKey contextKey = "claims"
func JWTProtect(keyURL, cookieName string) func(http.Handler) http.Handler { func JWTProtect(keyURL, cookieName string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
authCookie, err := r.Cookie(cookieName) var jwtValue string
if err != nil { var isRPC bool
RespondWithJSONError(w, fmt.Errorf("jwt: error reading cookie: %s", err), http.StatusForbidden) if authCookie, err := r.Cookie(cookieName); err == nil {
jwtValue = authCookie.Value
} else {
if header := r.Header.Get("X-RPC-Authentication"); header != "" {
jwtValue = header
isRPC = true
}
}
if jwtValue == "" {
RespondWithJSONError(w, errors.New("jwt: could not infer JWT value from cookie or header"), http.StatusForbidden)
return return
} }
@ -56,7 +65,7 @@ func JWTProtect(keyURL, cookieName string) func(http.Handler) http.Handler {
return return
} }
token, jwtErr := jwt.ParseVerify(strings.NewReader(authCookie.Value), jwa.RS256, pubKey) token, jwtErr := jwt.ParseVerify(strings.NewReader(jwtValue), jwa.RS256, pubKey)
if jwtErr != nil { if jwtErr != nil {
RespondWithJSONError(w, fmt.Errorf("jwt: error parsing token: %v", jwtErr), http.StatusForbidden) RespondWithJSONError(w, fmt.Errorf("jwt: error parsing token: %v", jwtErr), http.StatusForbidden)
return return
@ -68,8 +77,19 @@ func JWTProtect(keyURL, cookieName string) func(http.Handler) http.Handler {
} }
privateClaims, _ := token.Get("priv") privateClaims, _ := token.Get("priv")
r = r.WithContext(context.WithValue(r.Context(), ClaimsContextKey, privateClaims)) if isRPC {
cast, ok := privateClaims.(map[string]interface{})
if !ok {
RespondWithJSONError(w, fmt.Errorf("jwt: malformed private claims section in token: %v", privateClaims), http.StatusBadRequest)
return
}
if cast["rpc"] != "1" {
RespondWithJSONError(w, errors.New("jwt: token claims do not allow the requested operation"), http.StatusForbidden)
return
}
}
r = r.WithContext(context.WithValue(r.Context(), ClaimsContextKey, privateClaims))
next.ServeHTTP(w, r) next.ServeHTTP(w, r)
}) })
} }