From a526a567672aca037a5994c3884e798a41a30839 Mon Sep 17 00:00:00 2001 From: ReAnzu Date: Sun, 25 Feb 2018 04:44:02 -0600 Subject: [PATCH] Users are only authenticated against their password on login, and to retrieve a token. * Passwords are wiped from the app and cookies after login and token retrieval * Tokens are revoked at the end of the session/logout * If the user chooses the "remember me" option, the token is stored in the cookie * A user interface to revoke tokens will be added --- client/js/api.js | 77 +++++++++++++++++-- config.yaml.dist | 4 + server/szurubooru/api/__init__.py | 1 + server/szurubooru/api/user_api.py | 2 +- server/szurubooru/api/user_token_api.py | 38 +++++++++ server/szurubooru/func/auth.py | 9 +++ server/szurubooru/func/user_tokens.py | 74 ++++++++++++++++++ server/szurubooru/func/users.py | 18 +++-- server/szurubooru/middleware/authenticator.py | 24 ++++-- .../a39c7f98a7fa_add_user_token_table.py | 34 ++++++++ server/szurubooru/model/__init__.py | 4 +- server/szurubooru/model/user.py | 18 +++++ server/szurubooru/rest/__init__.py | 1 + server/szurubooru/rest/routes.py | 16 ++++ 14 files changed, 298 insertions(+), 22 deletions(-) create mode 100644 server/szurubooru/api/user_token_api.py create mode 100644 server/szurubooru/func/user_tokens.py create mode 100644 server/szurubooru/migrations/versions/a39c7f98a7fa_add_user_token_table.py diff --git a/client/js/api.js b/client/js/api.js index abfeb6f0..608fc6f7 100644 --- a/client/js/api.js +++ b/client/js/api.js @@ -15,6 +15,7 @@ class Api extends events.EventTarget { this.user = null; this.userName = null; this.userPassword = null; + this.userToken = null; this.cache = {}; this.allRanks = [ 'anonymous', @@ -87,11 +88,70 @@ class Api extends events.EventTarget { loginFromCookies() { const auth = cookies.getJSON('auth'); - return auth && auth.user && auth.password ? - this.login(auth.user, auth.password, true) : + return auth && auth.user && auth.token ? + this.login_with_token(auth.user, auth.token, true) : Promise.resolve(); } + login_with_token(userName, token, doRemember) { + this.cache = {}; + return new Promise((resolve, reject) => { + this.userName = userName; + this.userToken = token; + this.get('/user/' + userName + '?bump-login=true') + .then(response => { + const options = {}; + if (doRemember) { + options.expires = 365; + } + cookies.set( + 'auth', + {'user': userName, 'token': token}, + options); + this.user = response; + resolve(); + this.dispatchEvent(new CustomEvent('login')); + }, error => { + reject(error); + this.logout(); + }); + }); + } + + get_token(userName, options) { + return new Promise((resolve, reject) => { + this.post('/user-tokens', {}) + .then(response => { + cookies.set( + 'auth', + {'user': userName, 'token': response.token}, + options); + this.userName = userName; + this.userToken = response.token; + this.userPassword = null; + }, error => { + reject(error); + }); + }); + } + + delete_token(userName, userToken) { + return new Promise((resolve, reject) => { + this.delete('/user-tokens/' + userToken, {}) + .then(response => { + const options = {}; + cookies.set( + 'auth', + {'user': userName, 'token': null}, + options); + this.userName = userName; + this.userToken = null; + }, error => { + reject(error); + }); + }); + } + login(userName, userPassword, doRemember) { this.cache = {}; return new Promise((resolve, reject) => { @@ -103,10 +163,7 @@ class Api extends events.EventTarget { if (doRemember) { options.expires = 365; } - cookies.set( - 'auth', - {'user': userName, 'password': userPassword}, - options); + this.get_token(this.userName, options); this.user = response; resolve(); this.dispatchEvent(new CustomEvent('login')); @@ -118,9 +175,11 @@ class Api extends events.EventTarget { } logout() { + this.delete_token(this.userName, this.userToken); this.user = null; this.userName = null; this.userPassword = null; + this.userToken = null; this.dispatchEvent(new CustomEvent('logout')); } @@ -258,7 +317,11 @@ class Api extends events.EventTarget { } try { - if (this.userName && this.userPassword) { + if (this.userName && this.userToken) { + req.auth = null; + req.set('Authorization', 'Token ' + new Buffer(this.userName + ":" + this.userToken).toString('base64')) + } + else if (this.userName && this.userPassword) { req.auth( this.userName, encodeURIComponent(this.userPassword) diff --git a/config.yaml.dist b/config.yaml.dist index 18eb8611..0b10dbcf 100644 --- a/config.yaml.dist +++ b/config.yaml.dist @@ -86,6 +86,10 @@ privileges: 'users:delete:any': administrator 'users:delete:self': regular + 'user_token:list': regular + 'user_token:create': regular + 'user_token:delete': regular + 'posts:create:anonymous': regular 'posts:create:identified': regular 'posts:list': anonymous diff --git a/server/szurubooru/api/__init__.py b/server/szurubooru/api/__init__.py index 2a2d5af7..0d7f75f8 100644 --- a/server/szurubooru/api/__init__.py +++ b/server/szurubooru/api/__init__.py @@ -1,5 +1,6 @@ import szurubooru.api.info_api import szurubooru.api.user_api +import szurubooru.api.user_token_api import szurubooru.api.post_api import szurubooru.api.tag_api import szurubooru.api.tag_category_api diff --git a/server/szurubooru/api/user_api.py b/server/szurubooru/api/user_api.py index 95d8e4fe..aa0dbb9b 100644 --- a/server/szurubooru/api/user_api.py +++ b/server/szurubooru/api/user_api.py @@ -1,5 +1,5 @@ from typing import Any, Dict -from szurubooru import model, search, rest, config, errors +from szurubooru import model, search, rest from szurubooru.func import auth, users, serialization, versions diff --git a/server/szurubooru/api/user_token_api.py b/server/szurubooru/api/user_token_api.py new file mode 100644 index 00000000..8936d721 --- /dev/null +++ b/server/szurubooru/api/user_token_api.py @@ -0,0 +1,38 @@ +from typing import Dict + +from szurubooru import model, rest +from szurubooru.func import auth, user_tokens, serialization + + +def _serialize( + ctx: rest.Context, user_token: model.UserToken) -> rest.Response: + return user_tokens.serialize_user_token( + user_token, + ctx.user, + options=serialization.get_serialization_options(ctx)) + + +@rest.routes.get('/user-tokens/?') +def get_user_tokens(ctx: rest.Context, _params: Dict[str, str] = {}) -> rest.Response: + auth.verify_privilege(ctx.user, 'user_token:list') + user_token_list = user_tokens.get_user_tokens(ctx.user) + return { + "tokens": [_serialize(ctx, token) for token in user_token_list] + } + + +@rest.routes.post('/user-tokens/?') +def create_user_token(ctx: rest.Context, _params: Dict[str, str] = {}) -> rest.Response: + auth.verify_privilege(ctx.user, 'user_token:create') + user_token = user_tokens.create_user_token(ctx.user) + return _serialize(ctx, user_token) + + +@rest.routes.delete('/user-tokens/(?P[^/]+)/?') +def create_user_token(ctx: rest.Context, params: Dict[str, str]) -> rest.Response: + user_token = user_tokens.get_user_token_by_user_and_token(ctx.user, params['user_token']) + if user_token is not None: + auth.verify_privilege(ctx.user, 'user_token:delete') + ctx.session.delete(user_token) + ctx.session.commit() + return {} diff --git a/server/szurubooru/func/auth.py b/server/szurubooru/func/auth.py index d2bb69ac..a9c40b0f 100644 --- a/server/szurubooru/func/auth.py +++ b/server/szurubooru/func/auth.py @@ -6,6 +6,7 @@ from nacl.exceptions import InvalidkeyError from szurubooru import config, model, errors, db from szurubooru.func import util from nacl.pwhash import argon2id, verify +import uuid RANK_MAP = OrderedDict([ @@ -75,6 +76,10 @@ def is_valid_password(user: model.User, password: str) -> bool: return False +def is_valid_token(user_token: model.UserToken) -> bool: + return user_token is not None and user_token.enabled + + def has_privilege(user: model.User, privilege_name: str) -> bool: assert user all_ranks = list(RANK_MAP.keys()) @@ -99,3 +104,7 @@ def generate_authentication_token(user: model.User) -> str: digest.update(config.config['secret'].encode('utf8')) digest.update(user.password_salt.encode('utf8')) return digest.hexdigest() + + +def generate_authorization_token() -> str: + return uuid.uuid4().__str__() diff --git a/server/szurubooru/func/user_tokens.py b/server/szurubooru/func/user_tokens.py new file mode 100644 index 00000000..8c9a35de --- /dev/null +++ b/server/szurubooru/func/user_tokens.py @@ -0,0 +1,74 @@ +from datetime import datetime +from typing import Any, Optional, List, Dict, Callable + +import sqlalchemy as sa + +from szurubooru import db, model, rest +from szurubooru.func import auth, serialization, users + + +class UserTokenSerializer(serialization.BaseSerializer): + def __init__( + self, + user_token: model.UserToken, + auth_user: model.User) -> None: + self.user_token = user_token + self.auth_user = auth_user + + def _serializers(self) -> Dict[str, Callable[[], Any]]: + return { + 'user': self.serialize_user, + 'token': self.serialize_token, + 'enabled': self.serialize_enabled, + 'creationTime': self.serialize_creation_time, + 'lastLoginTime': self.serialize_last_edit_time, + } + + def serialize_user(self) -> Any: + return users.serialize_micro_user(self.user_token.user, self.auth_user) + + def serialize_creation_time(self) -> Any: + return self.user_token.creation_time + + def serialize_last_edit_time(self) -> Any: + return self.user_token.last_edit_time + + def serialize_token(self) -> Any: + return self.user_token.token + + def serialize_enabled(self) -> Any: + return self.user_token.enabled + + +def serialize_user_token( + user_token: Optional[model.UserToken], + auth_user: model.User, + options: List[str] = []) -> Optional[rest.Response]: + if not user_token: + return None + return UserTokenSerializer(user_token, auth_user).serialize(options) + + +def get_user_token_by_user_and_token(user: model.User, token: str) -> model.UserToken: + return (db.session.query(model.UserToken) + .filter(model.UserToken.user_id == user.user_id, model.UserToken.token == token) + .one_or_none()) + + +def get_user_tokens(user: model.User) -> List[model.UserToken]: + assert user + return (db.session.query(model.UserToken) + .filter(sa.func.lower(model.UserToken.user_id) == sa.func.lower(user.user_id)) + .all()) + + +def create_user_token(user: model.User) -> model.UserToken: + assert user + user_token = model.UserToken() + user_token.user = user + user_token.token = auth.generate_authorization_token() + user_token.enabled = True + user_token.creation_time = datetime.utcnow() + db.session.add(user_token) + db.session.commit() + return user_token diff --git a/server/szurubooru/func/users.py b/server/szurubooru/func/users.py index ba6f67f2..19c62bf7 100644 --- a/server/szurubooru/func/users.py +++ b/server/szurubooru/func/users.py @@ -1,7 +1,9 @@ -import re -from typing import Any, Optional, Union, List, Dict, Callable from datetime import datetime +from typing import Any, Optional, Union, List, Dict, Callable + +import re import sqlalchemy as sa + from szurubooru import config, db, model, errors, rest from szurubooru.func import auth, util, serialization, files, images @@ -172,9 +174,9 @@ def get_user_count() -> int: def try_get_user_by_name(name: str) -> Optional[model.User]: return ( db.session - .query(model.User) - .filter(sa.func.lower(model.User.name) == sa.func.lower(name)) - .one_or_none()) + .query(model.User) + .filter(sa.func.lower(model.User.name) == sa.func.lower(name)) + .one_or_none()) def get_user_by_name(name: str) -> model.User: @@ -187,11 +189,11 @@ def get_user_by_name(name: str) -> model.User: def try_get_user_by_name_or_email(name_or_email: str) -> Optional[model.User]: return ( db.session - .query(model.User) - .filter( + .query(model.User) + .filter( (sa.func.lower(model.User.name) == sa.func.lower(name_or_email)) | (sa.func.lower(model.User.email) == sa.func.lower(name_or_email))) - .one_or_none()) + .one_or_none()) def get_user_by_name_or_email(name_or_email: str) -> model.User: diff --git a/server/szurubooru/middleware/authenticator.py b/server/szurubooru/middleware/authenticator.py index 644fe3b3..a2753583 100644 --- a/server/szurubooru/middleware/authenticator.py +++ b/server/szurubooru/middleware/authenticator.py @@ -1,7 +1,7 @@ import base64 from typing import Optional from szurubooru import db, model, errors, rest -from szurubooru.func import auth, users +from szurubooru.func import auth, users, user_tokens from szurubooru.rest.errors import HttpBadRequest @@ -13,19 +13,33 @@ def _authenticate(username: str, password: str) -> model.User: return user +def _authenticate_token(username: str, token: str) -> model.User: + """Try to authenticate user. Throw AuthError for invalid users.""" + user = users.get_user_by_name(username) + user_token = user_tokens.get_user_token_by_user_and_token(user, token) + if not auth.is_valid_token(user_token): + raise errors.AuthError('Invalid token.') + return user + + def _get_user(ctx: rest.Context) -> Optional[model.User]: if not ctx.has_header('Authorization'): return None try: auth_type, credentials = ctx.get_header('Authorization').split(' ', 1) - if auth_type.lower() != 'basic': + if auth_type.lower() == 'basic': + username, password = base64.decodebytes( + credentials.encode('ascii')).decode('utf8').split(':', 1) + return _authenticate(username, password) + elif auth_type.lower() == 'token': + username, token = base64.decodebytes( + credentials.encode('ascii')).decode('utf8').split(':', 1) + return _authenticate_token(username, token) + else: raise HttpBadRequest( 'ValidationError', 'Only basic HTTP authentication is supported.') - username, password = base64.decodebytes( - credentials.encode('ascii')).decode('utf8').split(':', 1) - return _authenticate(username, password) except ValueError as err: msg = ( 'Basic authentication header value are not properly formed. ' diff --git a/server/szurubooru/migrations/versions/a39c7f98a7fa_add_user_token_table.py b/server/szurubooru/migrations/versions/a39c7f98a7fa_add_user_token_table.py new file mode 100644 index 00000000..6e6ac5d8 --- /dev/null +++ b/server/szurubooru/migrations/versions/a39c7f98a7fa_add_user_token_table.py @@ -0,0 +1,34 @@ +''' +Added a user_token table for API authorization + +Revision ID: a39c7f98a7fa +Created at: 2018-02-25 01:31:27.345595 +''' + +import sqlalchemy as sa +from alembic import op + + +revision = 'a39c7f98a7fa' +down_revision = '9ef1a1643c2a' +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table('user_token', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('token', sa.Unicode(length=36), nullable=False), + sa.Column('enabled', sa.Boolean(), nullable=False), + sa.Column('creation_time', sa.DateTime(), nullable=False), + sa.Column('last_edit_time', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_user_token_user_id'), 'user_token', ['user_id'], unique=False) + + +def downgrade(): + op.drop_index(op.f('ix_user_token_user_id'), table_name='user_token') + op.drop_table('user_token') diff --git a/server/szurubooru/model/__init__.py b/server/szurubooru/model/__init__.py index ad2231c2..9f87b489 100644 --- a/server/szurubooru/model/__init__.py +++ b/server/szurubooru/model/__init__.py @@ -1,5 +1,7 @@ from szurubooru.model.base import Base -from szurubooru.model.user import User +from szurubooru.model.user import ( + User, + UserToken) from szurubooru.model.tag_category import TagCategory from szurubooru.model.tag import Tag, TagName, TagSuggestion, TagImplication from szurubooru.model.post import ( diff --git a/server/szurubooru/model/user.py b/server/szurubooru/model/user.py index d355a143..7ca1a10e 100644 --- a/server/szurubooru/model/user.py +++ b/server/szurubooru/model/user.py @@ -84,3 +84,21 @@ class User(Base): 'version_id_col': version, 'version_id_generator': False, } + + +class UserToken(Base): + __tablename__ = 'user_token' + + user_token_id = sa.Column('id', sa.Integer, primary_key=True) + user_id = sa.Column( + 'user_id', + sa.Integer, + sa.ForeignKey('user.id', ondelete='CASCADE'), + nullable=False, + index=True) + token = sa.Column('token', sa.Unicode(36), nullable=False) + enabled = sa.Column('enabled', sa.Boolean, nullable=False, default=True) + creation_time = sa.Column('creation_time', sa.DateTime, nullable=False) + last_edit_time = sa.Column('last_edit_time', sa.DateTime) + + user = sa.orm.relationship('User') diff --git a/server/szurubooru/rest/__init__.py b/server/szurubooru/rest/__init__.py index 14a3e305..d6b3ef28 100644 --- a/server/szurubooru/rest/__init__.py +++ b/server/szurubooru/rest/__init__.py @@ -1,2 +1,3 @@ from szurubooru.rest.app import application from szurubooru.rest.context import Context, Response +import szurubooru.rest.routes diff --git a/server/szurubooru/rest/routes.py b/server/szurubooru/rest/routes.py index 569cbe1f..41477710 100644 --- a/server/szurubooru/rest/routes.py +++ b/server/szurubooru/rest/routes.py @@ -1,8 +1,12 @@ +import logging + from typing import Callable, Dict from collections import defaultdict from szurubooru.rest.context import Context, Response +logger = logging.getLogger(__name__) + # pylint: disable=invalid-name RouteHandler = Callable[[Context, Dict[str, str]], Response] routes = defaultdict(dict) # type: Dict[str, Dict[str, RouteHandler]] @@ -11,6 +15,9 @@ routes = defaultdict(dict) # type: Dict[str, Dict[str, RouteHandler]] def get(url: str) -> Callable[[RouteHandler], RouteHandler]: def wrapper(handler: RouteHandler) -> RouteHandler: routes[url]['GET'] = handler + logger.info( + 'Registered [GET] %s (user=%s, queries=%d)', + url) return handler return wrapper @@ -18,6 +25,9 @@ def get(url: str) -> Callable[[RouteHandler], RouteHandler]: def put(url: str) -> Callable[[RouteHandler], RouteHandler]: def wrapper(handler: RouteHandler) -> RouteHandler: routes[url]['PUT'] = handler + logger.info( + 'Registered [PUT] %s (user=%s, queries=%d)', + url) return handler return wrapper @@ -25,6 +35,9 @@ def put(url: str) -> Callable[[RouteHandler], RouteHandler]: def post(url: str) -> Callable[[RouteHandler], RouteHandler]: def wrapper(handler: RouteHandler) -> RouteHandler: routes[url]['POST'] = handler + logger.info( + 'Registered [POST] %s (user=%s, queries=%d)', + url) return handler return wrapper @@ -32,5 +45,8 @@ def post(url: str) -> Callable[[RouteHandler], RouteHandler]: def delete(url: str) -> Callable[[RouteHandler], RouteHandler]: def wrapper(handler: RouteHandler) -> RouteHandler: routes[url]['DELETE'] = handler + logger.info( + 'Registered [DELETE] %s (user=%s, queries=%d)', + url) return handler return wrapper