diff --git a/jarr/api/auth.py b/jarr/api/auth.py index 810536e4..ff7be29a 100644 --- a/jarr/api/auth.py +++ b/jarr/api/auth.py @@ -1,5 +1,5 @@ import random -from datetime import datetime, timedelta, timezone +from datetime import datetime, timezone from flask import render_template from flask_jwt_extended import (create_access_token, create_refresh_token, @@ -9,7 +9,7 @@ from jarr.bootstrap import conf from jarr.controllers import UserController from jarr.lib import emails -from jarr.lib.utils import utc_now +from jarr.lib.utils import get_auth_expiration_delay, utc_now from jarr.metrics import SERVER from werkzeug.exceptions import BadRequest, Forbidden @@ -45,12 +45,6 @@ ) -def _get_declared_expiration_delay(factor=3 / 4) -> str: - declared_delay_sec = conf.auth.expiration_sec * factor - declared_delay = datetime.utcnow() + timedelta(seconds=declared_delay_sec) - return declared_delay.replace(tzinfo=timezone.utc).isoformat() - - @auth_ns.route("") class LoginResource(Resource): @staticmethod @@ -77,7 +71,7 @@ def post(): return { "access_token": f"Bearer {access_token}", "refresh_token": f"Bearer {refresh_token}", - "access_token_expires_at": _get_declared_expiration_delay(), + "access_token_expires_at": get_auth_expiration_delay(), }, 200 @@ -98,7 +92,7 @@ def post(): SERVER.labels(method="get", uri="/auth/refresh", result="2XX").inc() return { "access_token": f"Bearer {access_token}", - "access_token_expires_at": _get_declared_expiration_delay(), + "access_token_expires_at": get_auth_expiration_delay(), }, 200 diff --git a/jarr/api/oauth.py b/jarr/api/oauth.py index 03de19c8..04e62131 100644 --- a/jarr/api/oauth.py +++ b/jarr/api/oauth.py @@ -1,24 +1,26 @@ import json -from flask import current_app, session +from flask import session +from flask_jwt_extended import create_access_token, create_refresh_token from flask_restx import Namespace, Resource -from rauth import OAuth1Service, OAuth2Service -from werkzeug.exceptions import BadRequest, NotFound, UnprocessableEntity - from jarr.api.auth import model from jarr.api.common import get_ui_url -from jarr.lib.utils import utc_now from jarr.bootstrap import conf from jarr.controllers import UserController +from jarr.lib.utils import get_auth_expiration_delay, utc_now from jarr.metrics import SERVER +from rauth import OAuth1Service, OAuth2Service +from werkzeug.exceptions import BadRequest, NotFound, UnprocessableEntity -oauth_ns = Namespace('oauth', description="OAuth related operations") +oauth_ns = Namespace("oauth", description="OAuth related operations") oauth_callback_parser = oauth_ns.parser() -oauth_callback_parser.add_argument('code', type=str, - required=True, store_missing=False) +oauth_callback_parser.add_argument( + "code", type=str, required=True, store_missing=False +) oauth1_callback_parser = oauth_ns.parser() -oauth1_callback_parser.add_argument('oauth_verifier', type=str, - required=True, store_missing=False) +oauth1_callback_parser.add_argument( + "oauth_verifier", type=str, required=True, store_missing=False +) # FROM http://blog.miguelgrinberg.com/post/oauth-authentication-with-flask @@ -32,12 +34,12 @@ class OAuthSignInMixin(Resource): # pragma: no cover def service(self): srv_cfg = getattr(conf.oauth, self.provider) return OAuth2Service( - name=self.provider, - client_id=srv_cfg.id, - client_secret=srv_cfg.secret, - base_url=self.base_url, - access_token_url=self.access_token_url, - authorize_url=self.authorize_url, + name=self.provider, + client_id=srv_cfg.id, + client_secret=srv_cfg.secret, + base_url=self.base_url, + access_token_url=self.access_token_url, + authorize_url=self.authorize_url, ) @classmethod @@ -50,7 +52,7 @@ def process_ids(cls, social_id, username, email): # pragma: no cover labels = {"method": "get", "uri": "/oauth/callback/" + cls.provider} if social_id is None: SERVER.labels(result="4XX", **labels).inc() - raise UnprocessableEntity('No social id, authentication failed') + raise UnprocessableEntity("No social id, authentication failed") ucontr = UserController() try: user = ucontr.get(**{f"{cls.provider}_identity": social_id}) @@ -58,184 +60,226 @@ def process_ids(cls, social_id, username, email): # pragma: no cover user = None if not user and not conf.oauth.allow_signup: SERVER.labels(result="4XX", **labels).inc() - raise BadRequest('Account creation is not allowed through OAuth.') + raise BadRequest("Account creation is not allowed through OAuth.") if not user: if username and not ucontr.read(login=username).count(): login = username else: login = f"{cls.provider}_{username or social_id}" - user = ucontr.create(**{f"{cls.provider}_identity": social_id, - 'login': login, 'email': email}) - ucontr.update({"id": user.id}, {"last_connection": utc_now(), - "renew_password_token": ""}) - jwt_ext = current_app.extensions['jwt'] - access_token = jwt_ext.jwt_encode_callback(user).decode('utf8') + new_user = { + f"{cls.provider}_identity": social_id, + "login": login, + "email": email, + } + user = ucontr.create(**new_user) + access_token = create_access_token(identity=user) + refresh_token = create_refresh_token(identity=user) + ucontr.update( + {"id": user.id}, + {"last_connection": utc_now(), "renew_password_token": ""}, + ) SERVER.labels(result="2XX", **labels).inc() - return {"access_token": f"Bearer {access_token}"}, 200 + return { + "access_token": f"Bearer {access_token}", + "refresh_token": f"Bearer {refresh_token}", + "access_token_expires_at": get_auth_expiration_delay(), + }, 200 class GoogleSignInMixin(OAuthSignInMixin): - provider = 'google' - base_url = 'https://www.googleapis.com/oauth2/v1/' - access_token_url = 'https://accounts.google.com/o/oauth2/token' - authorize_url = 'https://accounts.google.com/o/oauth2/auth' + provider = "google" + base_url = "https://www.googleapis.com/oauth2/v1/" + access_token_url = "https://accounts.google.com/o/oauth2/token" + authorize_url = "https://accounts.google.com/o/oauth2/auth" -@oauth_ns.route('/google') +@oauth_ns.route("/google") class GoogleAuthorizeUrl(GoogleSignInMixin): - @oauth_ns.response(301, 'Redirect to provider authorize URL') + @oauth_ns.response(301, "Redirect to provider authorize URL") def get(self): - SERVER.labels(result="3XX", method="get", - uri="/oauth/" + self.provider).inc() - return None, 301, {'Location': self.service.get_authorize_url( - scope='email', response_type='code', - redirect_uri=self.get_callback_url())} + SERVER.labels( + result="3XX", method="get", uri=f"/oauth/{self.provider}" + ).inc() + location = self.service.get_authorize_url( + scope="email", + response_type="code", + redirect_uri=self.get_callback_url(), + ) + return None, 301, {"Location": location} -@oauth_ns.route('/callback/google') +@oauth_ns.route("/callback/google") class GoogleCallback(GoogleSignInMixin): @oauth_ns.expect(oauth_callback_parser, validate=True) @oauth_ns.response(200, "Authenticate and returns token", model=model) - @oauth_ns.response(400, "Identification ended up creating an account " - "and current configuration doesn't allow it") + @oauth_ns.response( + 400, + "Identification ended up creating an account " + "and current configuration doesn't allow it", + ) @oauth_ns.response(422, "Auth provider didn't send identity") def post(self): - code = oauth_callback_parser.parse_args()['code'] + code = oauth_callback_parser.parse_args()["code"] oauth_session = self.service.get_auth_session( - data={'code': code, - 'grant_type': 'authorization_code', - 'redirect_uri': self.get_callback_url()}, - decoder=lambda x: json.loads(x.decode('utf8')), + data={ + "code": code, + "grant_type": "authorization_code", + "redirect_uri": self.get_callback_url(), + }, + decoder=lambda x: json.loads(x.decode("utf8")), ) - info = oauth_session.get('userinfo').json() + info = oauth_session.get("userinfo").json() return self.process_ids( - info['id'], info.get('name'), info.get('email')) + info["id"], info.get("name"), info.get("email") + ) class TwitterSignInMixin(OAuthSignInMixin): - provider = 'twitter' + provider = "twitter" @property def service(self): return OAuth1Service( - name='twitter', + name="twitter", consumer_key=conf.oauth.twitter.id, consumer_secret=conf.oauth.twitter.secret, - request_token_url='https://api.twitter.com/oauth/request_token', - authorize_url='https://api.twitter.com/oauth/authorize', - access_token_url='https://api.twitter.com/oauth/access_token', - base_url='https://api.twitter.com/1.1/' + request_token_url="https://api.twitter.com/oauth/request_token", + authorize_url="https://api.twitter.com/oauth/authorize", + access_token_url="https://api.twitter.com/oauth/access_token", + base_url="https://api.twitter.com/1.1/", ) -@oauth_ns.route('/twitter') +@oauth_ns.route("/twitter") class TwitterAuthorizeURL(TwitterSignInMixin): - @oauth_ns.response(301, 'Redirect to provider authorize URL') + @oauth_ns.response(301, "Redirect to provider authorize URL") def get(self): request_token = self.service.get_request_token( - params={'oauth_callback': self.get_callback_url()}) - session['request_token'] = request_token - return None, 301, { - 'Location': self.service.get_authorize_url(request_token[0])} + params={"oauth_callback": self.get_callback_url()} + ) + session["request_token"] = request_token + location = self.service.get_authorize_url(request_token[0]) + return None, 301, {"Location": location} -@oauth_ns.route('/twitter') +@oauth_ns.route("/twitter") class TwitterCallback(TwitterSignInMixin): @oauth_ns.expect(oauth_callback_parser, validate=True) @oauth_ns.response(200, "Authenticate and returns token", model=model) - @oauth_ns.response(400, "Identification ended up creating an account " - "and current configuration doesn't allow it") + @oauth_ns.response( + 400, + "Identification ended up creating an account " + "and current configuration doesn't allow it", + ) @oauth_ns.response(422, "Auth provider didn't send identity") def post(self): - oauth_verifier = oauth_callback_parser.parse_args()['oauth_verifier'] - request_token = session.pop('request_token') + oauth_verifier = oauth_callback_parser.parse_args()["oauth_verifier"] + request_token = session.pop("request_token") oauth_session = self.service.get_auth_session( - request_token[0], - request_token[1], - data={'oauth_verifier': oauth_verifier} + request_token[0], + request_token[1], + data={"oauth_verifier": oauth_verifier}, ) - info = oauth_session.get('account/verify_credentials.json').json() - social_id = 'twitter$' + str(info.get('id')) - login = info.get('screen_name') + info = oauth_session.get("account/verify_credentials.json").json() + social_id = "twitter$" + str(info.get("id")) + login = info.get("screen_name") return self.process_ids(social_id, login, None) class FacebookSignInMixin(OAuthSignInMixin): - provider = 'facebook' - base_url = 'https://graph.facebook.com/' - authorize_url = 'https://graph.facebook.com/oauth/authorize' - access_token_url = 'https://graph.facebook.com/oauth/access_token' + provider = "facebook" + base_url = "https://graph.facebook.com/" + authorize_url = "https://graph.facebook.com/oauth/authorize" + access_token_url = "https://graph.facebook.com/oauth/access_token" -@oauth_ns.route('/facebook') +@oauth_ns.route("/facebook") class FacebookAuthorizeUrl(FacebookSignInMixin): - @oauth_ns.response(301, 'Redirect to provider authorize URL') + @oauth_ns.response(301, "Redirect to provider authorize URL") def get(self): - return None, 301, {'Location': self.service.get_authorize_url( - scope='email', response_type='code', - redirect_uri=self.get_callback_url())} + location = self.service.get_authorize_url( + scope="email", + response_type="code", + redirect_uri=self.get_callback_url(), + ) + return None, 301, {"Location": location} -@oauth_ns.route('/facebook') + +@oauth_ns.route("/facebook") class FacebookCallback(FacebookSignInMixin): @oauth_ns.expect(oauth_callback_parser, validate=True) @oauth_ns.response(200, "Authenticate and returns token", model=model) - @oauth_ns.response(400, "Identification ended up creating an account " - "and current configuration doesn't allow it") + @oauth_ns.response( + 400, + "Identification ended up creating an account " + "and current configuration doesn't allow it", + ) @oauth_ns.response(422, "Auth provider didn't send identity") def post(self): - code = oauth_callback_parser.parse_args()['code'] + code = oauth_callback_parser.parse_args()["code"] oauth_session = self.service.get_auth_session( - data={'code': code, - 'grant_type': 'authorization_code', - 'redirect_uri': self.get_callback_url()} + data={ + "code": code, + "grant_type": "authorization_code", + "redirect_uri": self.get_callback_url(), + } ) - info = oauth_session.get('me?fields=id,email').json() + info = oauth_session.get("me?fields=id,email").json() # Facebook doesn't provide login, so the email is used - social_id = 'facebook$' + info['id'] - username = info.get('email').split('@')[0] - email = info.get('email') + social_id = "facebook$" + info["id"] + username = info.get("email").split("@")[0] + email = info.get("email") return self.process_ids(social_id, username, email) class LinuxFrSignInMixin(OAuthSignInMixin): # pragma: no cover - provider = 'linuxfr' - base_url = 'https://linuxfr.org/' - authorize_url = 'https://linuxfr.org/api/oauth/authorize' - access_token_url = 'https://linuxfr.org/api/oauth/token' + provider = "linuxfr" + base_url = "https://linuxfr.org/" + authorize_url = "https://linuxfr.org/api/oauth/authorize" + access_token_url = "https://linuxfr.org/api/oauth/token" -@oauth_ns.route('/linuxfr') +@oauth_ns.route("/linuxfr") class LinuxfrAuthorizeURL(LinuxFrSignInMixin): - @oauth_ns.response(301, 'Redirect to provider authorize URL') + @oauth_ns.response(301, "Redirect to provider authorize URL") def get(self): - return None, 301, {'Location': self.service.get_authorize_url( - scope='account', response_type='code', - redirect_uri=self.get_callback_url())} + location = self.service.get_authorize_url( + scope="account", + response_type="code", + redirect_uri=self.get_callback_url(), + ) + + return None, 301, {"Location": location} -@oauth_ns.route('/linuxfr') +@oauth_ns.route("/linuxfr") class LinuxfrCallback(LinuxFrSignInMixin): @oauth_ns.expect(oauth_callback_parser, validate=True) @oauth_ns.response(200, "Authenticate and returns token", model=model) - @oauth_ns.response(400, "Identification ended up creating an account " - "and current configuration doesn't allow it") + @oauth_ns.response( + 400, + "Identification ended up creating an account " + "and current configuration doesn't allow it", + ) @oauth_ns.response(422, "Auth provider didn't send identity") def post(self): - code = oauth_callback_parser.parse_args()['code'] + code = oauth_callback_parser.parse_args()["code"] oauth_session = self.service.get_auth_session( - data={'code': code, - 'grant_type': 'authorization_code', - 'redirect_uri': self.get_callback_url()}, - decoder=lambda x: json.loads(x.decode('utf8')), + data={ + "code": code, + "grant_type": "authorization_code", + "redirect_uri": self.get_callback_url(), + }, + decoder=lambda x: json.loads(x.decode("utf8")), ) - info = oauth_session.get('api/v1/me').json() - return self.process_ids(info['login'], info['login'], info['email']) + info = oauth_session.get("api/v1/me").json() + return self.process_ids(info["login"], info["login"], info["email"]) diff --git a/jarr/lib/utils.py b/jarr/lib/utils.py index 82ebe240..c10d4003 100644 --- a/jarr/lib/utils.py +++ b/jarr/lib/utils.py @@ -2,7 +2,7 @@ import re import types import urllib -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from enum import Enum from hashlib import md5, sha1 @@ -23,6 +23,14 @@ ) +def get_auth_expiration_delay(factor=3 / 4) -> str: + from jarr.bootstrap import conf # prevent circular import + + declared_delay_sec = conf.auth.expiration_sec * factor + declared_delay = datetime.utcnow() + timedelta(seconds=declared_delay_sec) + return declared_delay.replace(tzinfo=timezone.utc).isoformat() + + def utc_now(): return datetime.utcnow().replace(tzinfo=timezone.utc)