lenticular_cloud2/lenticular_cloud/views/auth.py

298 lines
12 KiB
Python
Raw Normal View History

2020-05-09 18:00:07 +00:00
2023-12-25 17:55:20 +00:00
from base64 import b64encode, b64decode, urlsafe_b64decode
import crypt
from datetime import datetime, timedelta
import jwt
from flask import request, url_for, jsonify, Blueprint, redirect, current_app, session
2020-05-09 18:00:07 +00:00
from flask.templating import render_template
from flask.typing import ResponseReturnValue
2020-06-21 09:52:37 +00:00
import logging
2023-03-17 07:52:33 +00:00
from ory_hydra_client import models as ory_hydra_m
2023-12-25 17:55:20 +00:00
from ory_hydra_client.api.o_auth_2 import get_o_auth_2_consent_request, accept_o_auth_2_consent_request, accept_o_auth_2_login_request, get_o_auth_2_login_request, accept_o_auth_2_login_request, accept_o_auth_2_logout_request, get_o_auth_2_login_request
from ory_hydra_client.models import TheRequestPayloadUsedToAcceptAConsentRequest, GenericError
from typing import Optional
2023-12-25 17:55:20 +00:00
from urllib.parse import urlparse
2023-12-17 16:10:41 +00:00
from uuid import uuid4, UUID
2023-12-25 17:55:20 +00:00
import webauthn
from webauthn.helpers.structs import (
AuthenticatorSelectionCriteria,
PublicKeyCredentialDescriptor,
ResidentKeyRequirement,
UserVerificationRequirement,
)
from ..model import db, User, PasskeyCredential
2020-05-27 19:16:14 +00:00
from ..form.auth import ConsentForm, LoginForm, RegistrationForm
2020-05-09 18:00:07 +00:00
from ..auth_providers import AUTH_PROVIDER_LIST
from ..hydra import hydra_service
2020-05-09 18:00:07 +00:00
2020-06-21 09:52:37 +00:00
logger = logging.getLogger(__name__)
2020-05-21 11:20:27 +00:00
auth_views = Blueprint('auth', __name__, url_prefix='/auth')
2020-05-21 11:20:27 +00:00
@auth_views.route('/consent', methods=['GET', 'POST'])
2022-02-20 15:53:24 +00:00
async def consent() -> ResponseReturnValue:
2020-05-21 11:20:27 +00:00
"""Always grant consent."""
# DUMMPY ONLY
2020-05-09 18:00:07 +00:00
form = ConsentForm()
2022-02-11 15:09:40 +00:00
remember_for = 60*60*24*30 # remember for 30 days
#try:
2023-03-17 07:52:33 +00:00
consent_request = await get_o_auth_2_consent_request.asyncio(consent_challenge=request.args['consent_challenge'],_client=hydra_service.hydra_client)
2023-03-17 07:52:33 +00:00
if consent_request is None or isinstance( consent_request, ory_hydra_m.OAuth20RedirectBrowserTo):
return redirect(url_for('frontend.index'))
2020-05-30 17:00:08 +00:00
requested_scope = consent_request.requested_scope
requested_audiences = consent_request.requested_access_token_audience
if form.validate_on_submit() or consent_request.skip:
2023-12-17 16:10:41 +00:00
2023-12-25 18:44:38 +00:00
if type(consent_request.subject) != str:
logger.error("not set subject `consent_request.subject`")
return 'internal error', 500
2023-12-17 16:10:41 +00:00
uid = UUID(consent_request.subject)
user = User.query.get(uid)
2022-07-15 08:53:06 +00:00
if user is None:
2023-12-25 18:44:38 +00:00
logger.error("user not found, even if it should exist")
2022-07-15 08:53:06 +00:00
return 'internal error', 500
2023-03-17 07:52:33 +00:00
access_token = {
'name': str(user.username),
2020-05-30 21:43:55 +00:00
'preferred_username': str(user.username),
2022-04-08 19:29:23 +00:00
'username': str(user.username),
2020-05-30 21:43:55 +00:00
'email': str(user.email),
2020-05-30 21:33:59 +00:00
'email_verified': True,
2023-01-13 16:08:27 +00:00
#'given_name': str(user.username),
#'family_name': '-',
'groups': [group.name for group in user.groups]
2020-05-30 21:33:59 +00:00
}
2023-03-17 07:52:33 +00:00
id_token = {}
if isinstance(requested_scope, list) and 'openid' in requested_scope:
2023-03-17 07:52:33 +00:00
id_token = access_token
body = TheRequestPayloadUsedToAcceptAConsentRequest(
grant_scope= requested_scope,
grant_access_token_audience= requested_audiences,
remember= form.data['remember'],
remember_for= remember_for,
2023-03-17 07:52:33 +00:00
session= ory_hydra_m.PassSessionDataToAConsentRequest(
access_token= access_token,
id_token= id_token
)
)
2023-03-17 07:52:33 +00:00
resp = await accept_o_auth_2_consent_request.asyncio(_client=hydra_service.hydra_client,
json_body=body,
consent_challenge=consent_request.challenge)
if resp is None or isinstance( resp, GenericError):
return 'internal error, could not forward request', 503
return redirect(resp.redirect_to)
return render_template(
'auth/consent.html.j2',
form=form,
client=consent_request.client,
requested_scope=requested_scope,
requested_audiences=requested_audiences)
2020-05-09 18:00:07 +00:00
@auth_views.route('/login', methods=['GET', 'POST'])
2022-02-20 15:53:24 +00:00
async def login() -> ResponseReturnValue:
2023-12-25 17:55:20 +00:00
secret_key = current_app.config['SECRET_KEY']
public_url = urlparse(current_app.config['PUBLIC_URL'])
2020-05-21 11:20:27 +00:00
login_challenge = request.args.get('login_challenge')
if login_challenge is None:
return 'login_challenge missing', 400
2023-03-17 07:52:33 +00:00
login_request = await get_o_auth_2_login_request.asyncio(_client=hydra_service.hydra_client, login_challenge=login_challenge)
if login_request is None or isinstance( login_request, ory_hydra_m.OAuth20RedirectBrowserTo):
2022-02-06 22:57:01 +00:00
logger.exception("could not fetch login request")
return redirect(url_for('frontend.index'))
2020-05-21 11:20:27 +00:00
2023-12-25 17:55:20 +00:00
## passkey
options = webauthn.generate_authentication_options(
rp_id = public_url.hostname,
user_verification = UserVerificationRequirement.REQUIRED,
challenge=webauthn.helpers.generate_challenge(32)
)
token = jwt.encode({
'challenge': b64encode(options.challenge).decode(),
'iat': datetime.utcnow() - timedelta(minutes=1),
'nbf': datetime.utcnow(),
'exp': datetime.utcnow() + timedelta(minutes=15),
}, secret_key, algorithm="HS256"
)
##
2020-05-21 11:20:27 +00:00
if login_request.skip:
2023-03-17 07:52:33 +00:00
resp = await accept_o_auth_2_login_request.asyncio(_client=hydra_service.hydra_client,
login_challenge=login_challenge,
2023-03-17 07:52:33 +00:00
json_body=ory_hydra_m.HandledLoginRequestIsTheRequestPayloadUsedToAcceptALoginRequest(subject=login_request.subject))
if resp is None or isinstance( resp, GenericError):
return 'internal error, could not forward request', 503
2020-05-21 11:20:27 +00:00
return redirect(resp.redirect_to)
2020-05-09 18:00:07 +00:00
form = LoginForm()
if form.validate_on_submit():
2022-07-15 08:53:06 +00:00
user = User.query.filter_by(username=form.data['name']).first() # type: Optional[User]
2020-05-13 18:08:28 +00:00
if user:
session['username'] = str(user.username)
else:
session['user'] = None
2020-05-09 18:00:07 +00:00
session['auth_providers'] = []
return redirect(
url_for('auth.login_auth', login_challenge=login_challenge))
2023-12-25 17:55:20 +00:00
return render_template(
'auth/login.html.j2',
form=form,
options=webauthn.options_to_json(options),
token=token,
login_challenge=login_challenge,
)
2020-05-09 18:00:07 +00:00
@auth_views.route('/login/auth', methods=['GET', 'POST'])
2022-02-20 15:53:24 +00:00
async def login_auth() -> ResponseReturnValue:
2020-05-21 11:20:27 +00:00
login_challenge = request.args.get('login_challenge')
if login_challenge is None:
return 'missing login_challenge, bad request', 400
2023-03-17 07:52:33 +00:00
login_request = await get_o_auth_2_login_request.asyncio(_client=hydra_service.hydra_client, login_challenge=login_challenge)
if login_request is None:
return redirect(url_for('frontend.index'))
2020-05-09 18:00:07 +00:00
if 'username' not in session:
return redirect(url_for('auth.login'))
auth_forms = {}
2023-10-09 19:58:44 +00:00
user = User.query.filter_by(username=session['username']).first_or_404()
2020-05-09 18:00:07 +00:00
for auth_provider in AUTH_PROVIDER_LIST:
form = auth_provider.get_form()
if auth_provider.get_name() not in session['auth_providers'] and\
auth_provider.check_auth(user, form):
session['auth_providers'].append(auth_provider.get_name())
2022-06-18 17:35:05 +00:00
session.modified = True
2020-05-09 18:00:07 +00:00
if auth_provider.get_name() not in session['auth_providers']:
auth_forms[auth_provider.get_name()]=form
2020-05-09 18:00:07 +00:00
if len(session['auth_providers']) >= 1: # TODO should be to for TOTP working
2020-05-21 11:20:27 +00:00
remember_me = True
# if db_user is None:
# db_user = User(username=session['username'])
# db.session.add(db_user)
# db.session.commit()
2020-05-21 11:20:27 +00:00
2023-10-09 19:58:44 +00:00
subject = str(user.id)
user.last_login = datetime.now()
db.session.commit()
2023-03-17 07:52:33 +00:00
resp = await accept_o_auth_2_login_request.asyncio(_client=hydra_service.hydra_client,
login_challenge=login_challenge, json_body=ory_hydra_m.HandledLoginRequestIsTheRequestPayloadUsedToAcceptALoginRequest(
subject=subject,
remember=remember_me,
))
if resp is None or isinstance( resp, GenericError):
return 'internal error, could not forward request', 503
2020-05-21 11:20:27 +00:00
return redirect(resp.redirect_to)
return render_template('auth/login_auth.html.j2', forms=auth_forms)
2020-05-09 18:00:07 +00:00
2023-12-25 17:55:20 +00:00
@auth_views.route('/passkey/verify', methods=['POST'])
async def passkey_verify() -> ResponseReturnValue:
secret_key = current_app.config['SECRET_KEY']
2023-12-25 18:55:29 +00:00
public_url = urlparse(current_app.config['PUBLIC_URL'])
2022-04-08 19:28:22 +00:00
2023-12-25 17:55:20 +00:00
data = request.get_json()
token = jwt.decode(data['token'], secret_key, algorithms=['HS256'])
challenge = urlsafe_b64decode(token['challenge'])
credential = data['credential']
credential_id = urlsafe_b64decode(credential['id'])
2022-04-08 19:28:22 +00:00
2023-12-25 17:55:20 +00:00
login_challenge = data['login_challenge']
if login_challenge is None:
return 'missing login_challenge, bad request', 400
login_request = await get_o_auth_2_login_request.asyncio(_client=hydra_service.hydra_client, login_challenge=login_challenge)
if login_request is None:
return redirect(url_for('frontend.index'))
passkey = PasskeyCredential.query.filter(PasskeyCredential.credential_id == credential_id).first_or_404()
result = webauthn.verify_authentication_response(
credential = credential,
2023-12-25 18:55:29 +00:00
expected_rp_id = public_url.hostname,
2023-12-25 17:55:20 +00:00
expected_challenge = challenge,
2023-12-25 18:55:29 +00:00
expected_origin = [ public_url.geturl() ],
2023-12-25 17:55:20 +00:00
credential_public_key = passkey.credential_public_key,
credential_current_sign_count = passkey.sign_count,
)
passkey.sign_count = result.new_sign_count
passkey.last_used = datetime.utcnow()
user = passkey.user
user.last_login = datetime.now()
subject = str(user.id)
resp = await accept_o_auth_2_login_request.asyncio(_client=hydra_service.hydra_client,
login_challenge=login_challenge, json_body=ory_hydra_m.HandledLoginRequestIsTheRequestPayloadUsedToAcceptALoginRequest(
subject=subject,
remember=True,
))
if resp is None or isinstance( resp, GenericError):
return 'internal error, could not forward request', 503
2022-04-08 19:28:22 +00:00
2023-12-25 17:55:20 +00:00
db.session.commit()
return jsonify({'redirect': resp.redirect_to})
2022-04-08 19:28:22 +00:00
2020-05-09 18:00:07 +00:00
@auth_views.route("/logout")
2022-02-20 15:53:24 +00:00
async def logout() -> ResponseReturnValue:
2020-05-21 11:20:27 +00:00
logout_challenge = request.args.get('logout_challenge')
if logout_challenge is None:
return 'invalid request, logout_challenge not set', 400
# TODO confirm
2023-03-17 07:52:33 +00:00
resp = await accept_o_auth_2_logout_request.asyncio(_client=hydra_service.hydra_client, logout_challenge=logout_challenge)
if resp is None or isinstance( resp, GenericError):
return 'internal error, could not forward request', 503
2020-05-21 11:20:27 +00:00
return redirect(resp.redirect_to)
2020-05-09 18:00:07 +00:00
2022-02-11 15:09:40 +00:00
@auth_views.route("/error", methods=["GET"])
def auth_error() -> ResponseReturnValue:
2022-02-11 15:09:40 +00:00
error = request.args.get('error')
error_description = request.args.get('error_description')
return render_template('auth/error.html.j2', error=error, error_description=error_description)
2020-05-27 19:16:14 +00:00
@auth_views.route("/sign_up", methods=["GET"])
2020-05-27 19:16:14 +00:00
def sign_up():
form = RegistrationForm()
return render_template('auth/sign_up.html.j2', form=form)
@auth_views.route("/sign_up", methods=["POST"])
def sign_up_submit():
2020-05-27 19:16:14 +00:00
form = RegistrationForm()
if form.validate_on_submit():
2022-06-17 11:38:49 +00:00
user = User()
2023-10-09 19:58:44 +00:00
user.id = uuid4()
2020-05-27 19:16:14 +00:00
user.username = form.data['username']
2022-06-17 11:38:49 +00:00
user.password_hashed = crypt.crypt(form.data['password'])
2020-05-27 19:16:14 +00:00
user.alternative_email = form.data['alternative_email']
db.session.add(user)
db.session.commit()
return jsonify({})
return jsonify({
'status': 'error',
'errors': form.errors
})
2022-05-27 10:35:28 +00:00
@auth_views.route("/oob", methods=["GET"])
def oob_token():
token_info = {
'code': request.args.get('code', default="", type=str),
'scope': request.args.get('scope', default="", type=str),
'state': request.args.get('state', default="", type=str),
}
return render_template('auth/oob.html.j2', token_info=token_info)