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
This commit is contained in:
parent
d6ee744777
commit
a526a56767
14 changed files with 298 additions and 22 deletions
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
||||
|
|
38
server/szurubooru/api/user_token_api.py
Normal file
38
server/szurubooru/api/user_token_api.py
Normal file
|
@ -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<user_token>[^/]+)/?')
|
||||
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 {}
|
|
@ -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__()
|
||||
|
|
74
server/szurubooru/func/user_tokens.py
Normal file
74
server/szurubooru/func/user_tokens.py
Normal file
|
@ -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
|
|
@ -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:
|
||||
|
|
|
@ -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. '
|
||||
|
|
|
@ -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')
|
|
@ -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 (
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -1,2 +1,3 @@
|
|||
from szurubooru.rest.app import application
|
||||
from szurubooru.rest.context import Context, Response
|
||||
import szurubooru.rest.routes
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in a new issue