2
0
mirror of https://github.com/offen/website.git synced 2024-10-18 12:10:25 +02: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:
- image: circleci/python:3.6
environment:
POSTGRES_CONNECTION_STRING: postgres://circle:test@localhost:5432/circle_test?sslmode=disable
HASHED_PASSWORD: JDJhJDEwJGpFRXJMOVVSQndZQlFQNjkxallkZi53aGp1cDMvRW5maGUvakZleG1pWFlnWEVXcU93ODBp
JWT_PRIVATE_KEY: |-
-----BEGIN PRIVATE KEY-----
@ -236,7 +237,10 @@ jobs:
a3B4L0waKzP5QWcO865n1HCUTnV+s4lNcphBDZCrSwTkXnVnQWVPCL7ssoQyM0u3
HQIDAQAB
-----END PUBLIC KEY-----
- image: circleci/postgres:11.2-alpine
environment:
- POSTGRES_USER=circle
- POSTGRES_PASSWORD=test
working_directory: ~/offen/accounts
steps:
- checkout:
@ -254,6 +258,16 @@ jobs:
paths:
- ~/offen/accounts/venv
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:
name: Run tests
command: |

View File

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

View File

@ -4,4 +4,7 @@ test:
fmt:
@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_sqlalchemy import SQLAlchemy
from flask_admin import Admin
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 os import environ
import base64
from flask import jsonify, render_template, make_response, request
from flask_cors import cross_origin
import requests
from flask_admin.contrib.sqla import ModelView
from wtforms import PasswordField, StringField, Form
from wtforms.validators import InputRequired, EqualTo
from passlib.hash import bcrypt
import jwt
from accounts import app
@app.route("/")
def home():
return render_template("index.html")
from accounts import db
from accounts.models import AccountUserAssociation
@app.route("/api/login", methods=["POST"])
@cross_origin(origins=[environ.get("CORS_ORIGIN", "*")], supports_credentials=True)
def post_login():
credentials = request.get_json(force=True)
class RemoteServerException(Exception):
status = 0
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", "")
expiry = datetime.utcnow() + timedelta(hours=24)
try:
encoded = jwt.encode(
{"ok": True, "exp": expiry}, private_key.encode(), algorithm="RS256"
).decode("utf-8")
except jwt.exceptions.PyJWTError as encode_error:
return jsonify({"error": str(encode_error), "status": 500}), 500
expiry = datetime.utcnow() + timedelta(seconds=10)
encoded = jwt.encode(
{"ok": True, "exp": expiry, "priv": {"rpc": "1"}},
private_key.encode(),
algorithm="RS256",
).decode("utf-8")
resp = make_response(jsonify({"ok": True}))
resp.set_cookie(
"auth",
encoded,
httponly=True,
expires=expiry,
path="/",
domain=environ.get("COOKIE_DOMAIN"),
samesite="strict"
r = requests.post(
"{}/accounts".format(environ.get("SERVER_HOST")),
json={"name": name, "account_id": account_id},
headers={"X-RPC-Authentication": encoded},
)
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"])
@cross_origin(origins=[environ.get("CORS_ORIGIN", "*")], supports_credentials=True)
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})
class AccountForm(Form):
name = StringField(
"Account Name",
validators=[InputRequired()],
description="This is the account name visible to users",
)
# This route is not supposed to be called by client-side applications, so
# no CORS configuration is added
@app.route("/api/key", methods=["GET"])
def key():
public_key = environ.get("JWT_PUBLIC_KEY", "").strip()
return jsonify({"key": public_key})
class AccountView(ModelView):
form = AccountForm
column_display_all_relations = True
column_list = ("account_id", "name")
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
black
pyyaml

View File

@ -1,6 +1,10 @@
Flask==1.0.2
Flask-Admin==1.5.3
Flask-Cors==3.0.8
Flask-SQLAlchemy==2.4.0
werkzeug==0.15.4
pyjwt[crypto]==1.7.1
passlib==1.7.1
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
staging: vault-staging.offen.dev
alpha: vault-alpha.offen.dev
serverHost:
production: server.offen.dev
staging: server-staging.offen.dev
alpha: server-alpha.offen.dev
domain:
production: accounts.offen.dev
staging: accounts-staging.offen.dev
@ -60,9 +64,10 @@ functions:
path: '{proxy+}'
method: any
environment:
USER: offen
CORS_ORIGIN: https://${self:custom.origin.${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_PUBLIC_KEY: '${ssm:/aws/reference/secretsmanager/${self:custom.stage}/accounts/jwtPublicKey~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:
POSTGRES_PASSWORD: develop
accounts_database:
image: postgres:11.2
environment:
POSTGRES_PASSWORD: develop
server:
build:
context: '.'
@ -31,6 +36,7 @@ services:
working_dir: /offen/server
volumes:
- .:/offen
- ./bootstrap.yml:/offen/server/bootstrap.yml
- serverdeps:/go/pkg/mod
environment:
POSTGRES_CONNECTION_STRING: postgres://postgres:develop@server_database:5432/postgres?sslmode=disable
@ -100,16 +106,20 @@ services:
working_dir: /offen/accounts
volumes:
- .:/offen
- ./bootstrap.yml:/offen/accounts/bootstrap.yml
- accountdeps:/root/.local
command: flask run --host 0.0.0.0
ports:
- 5000:5000
links:
- accounts_database
environment:
FLASK_APP: accounts
FLASK_APP: accounts:app
FLASK_ENV: development
POSTGRES_CONNECTION_STRING: postgres://postgres:develop@accounts_database:5432/postgres?sslmode=disable
CORS_ORIGIN: http://localhost:9977
# local password is `develop`
HASHED_PASSWORD: JDJhJDEwJGpFRXJMOVVSQndZQlFQNjkxallkZi53aGp1cDMvRW5maGUvakZleG1pWFlnWEVXcU93ODBp
SERVER_HOST: http://server:8080
SESSION_SECRET: vndJRFJTiyjfgtTF
JWT_PRIVATE_KEY: |-
-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCa6AEl0RUW43YS

View File

@ -26,9 +26,18 @@ const ClaimsContextKey contextKey = "claims"
func JWTProtect(keyURL, cookieName string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
authCookie, err := r.Cookie(cookieName)
if err != nil {
RespondWithJSONError(w, fmt.Errorf("jwt: error reading cookie: %s", err), http.StatusForbidden)
var jwtValue string
var isRPC bool
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
}
@ -56,7 +65,7 @@ func JWTProtect(keyURL, cookieName string) func(http.Handler) http.Handler {
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 {
RespondWithJSONError(w, fmt.Errorf("jwt: error parsing token: %v", jwtErr), http.StatusForbidden)
return
@ -68,8 +77,19 @@ func JWTProtect(keyURL, cookieName string) func(http.Handler) http.Handler {
}
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)
})
}