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:
parent
670032ee92
commit
d5be3feb7e
@ -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: |
|
||||
|
1
Makefile
1
Makefile
@ -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
|
||||
|
@ -4,4 +4,7 @@ test:
|
||||
fmt:
|
||||
@black .
|
||||
|
||||
.PHONY: test fmt
|
||||
bootstrap:
|
||||
@python -m scripts.bootstrap
|
||||
|
||||
.PHONY: test fmt bootstrap
|
||||
|
@ -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
93
accounts/accounts/api.py
Normal 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})
|
39
accounts/accounts/models.py
Normal file
39
accounts/accounts/models.py
Normal 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")
|
@ -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
0
accounts/bootstrap.yml
Executable file
@ -1,2 +1,3 @@
|
||||
pytest
|
||||
black
|
||||
pyyaml
|
||||
|
@ -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
|
||||
|
0
accounts/scripts/__init__.py
Normal file
0
accounts/scripts/__init__.py
Normal file
23
accounts/scripts/bootstrap.py
Executable file
23
accounts/scripts/bootstrap.py
Executable 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")
|
@ -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}}
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user