diff --git a/README.md b/README.md index 1cb1332..c117637 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,34 @@ Service which allows to manage savage loot distribution easy. * `is_tome`: is item tome gear or not, bool, required. * `piece`: item name, string, required. + + +### Users API + +* `DELETE /api/v1/login/{username}` + + Delete user with specified username. Parameters: + + * `username`: username to remove, required. + +* `POST /api/v1/login` + + Login with credentials. Parameters: + + * `username`: username to login, string, required. + * `password`: password to login, string, required. + +* `PUT /api/v1/login` + + Create new user. Parameters: + + * `username`: username to login, string, required. + * `password`: password to login, string, + * `permission`: user permission, one of `get`, `post`, optional, default `get`. + +* `POST /api/v1/logout` + + Logout. ## Configuration @@ -93,6 +121,14 @@ Service which allows to manage savage loot distribution easy. * `xivapi_key`: xivapi developer key, string, optional. * `xivapi_url`: xivapi base url, string, required. +* `auth` section + + Authentication settings. + + * `enabled`: whether authentication enabled or not, boolean, required. + * `root_username`: username of administrator, string, required. + * `root_password`: md5 hashed password of administrator, string, required. + * `sqlite` section Database settings for `sqlite` provider. diff --git a/migrations/20190910_01_tgBmx-users-table.py b/migrations/20190910_01_tgBmx-users-table.py new file mode 100644 index 0000000..f4ad5f7 --- /dev/null +++ b/migrations/20190910_01_tgBmx-users-table.py @@ -0,0 +1,17 @@ +''' +users table +''' + +from yoyo import step + +__depends__ = {} + +steps = [ + step('''create table users ( + user_id integer primary key, + username text not null, + password text not null, + permission text not null + )'''), + step('''create unique index users_username_idx on users(username)''') +] diff --git a/package/ini/ffxivbis.ini b/package/ini/ffxivbis.ini index d98a894..6bdd4cf 100644 --- a/package/ini/ffxivbis.ini +++ b/package/ini/ffxivbis.ini @@ -7,3 +7,4 @@ priority = is_required loot_count_bis loot_count_total loot_count loot_priority [web] host = 0.0.0.0 port = 8000 +templates = templates diff --git a/package/ini/ffxivbis.ini.d/auth.ini b/package/ini/ffxivbis.ini.d/auth.ini new file mode 100644 index 0000000..42a4b8d --- /dev/null +++ b/package/ini/ffxivbis.ini.d/auth.ini @@ -0,0 +1,4 @@ +[auth] +enabled = yes +root_username = admin +root_password = $1$R3j4sym6$HtvrKOJ66f7w3.9Zc3U6h1 \ No newline at end of file diff --git a/setup.py b/setup.py index a84d5bc..a178517 100644 --- a/setup.py +++ b/setup.py @@ -25,9 +25,11 @@ setup( packages=find_packages(exclude=['contrib', 'docs', 'tests']), install_requires=[ - 'aiohttp_jinja2', 'aiohttp', + 'aiohttp_jinja2', + 'aiohttp_security', 'Jinja2', + 'passlib', 'requests', 'yoyo_migrations' ], diff --git a/src/service/api/auth.py b/src/service/api/auth.py new file mode 100644 index 0000000..ea3a316 --- /dev/null +++ b/src/service/api/auth.py @@ -0,0 +1,51 @@ +# +# Copyright (c) 2019 Evgeniy Alekseev. +# +# This file is part of ffxivbis +# (see https://github.com/arcan1s/ffxivbis). +# +# License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause +# +from aiohttp.web import middleware, Request, Response +from aiohttp_security import AbstractAuthorizationPolicy, check_permission +from typing import Callable, Optional + +from service.core.database import Database + + +class AuthorizationPolicy(AbstractAuthorizationPolicy): + + def __init__(self, database: Database) -> None: + self.database = database + + async def authorized_userid(self, identity: str) -> Optional[str]: + user = self.database.get_user(identity) + return identity if user is not None else None + + async def permits(self, identity: str, permission: str, context: str = None) -> bool: + user = self.database.get_user(identity) + if user is None: + return False + if user.username != identity: + return False + if user.permission == 'admin': + return True + return permission == 'get' or user.permission == permission + + +def authorize_factory() -> Callable: + allowed_paths = {'/', '/favicon.ico', '/api/v1/login', '/api/v1/logout'} + + @middleware + async def authorize(request: Request, handler: Callable) -> Response: + if request.path.startswith('/admin'): + permission = 'admin' + else: + permission = 'get' if request.method in ('GET', 'HEAD') else 'post' + if request.path not in allowed_paths: + await check_permission(request, permission) + + return await handler(request) + + return authorize + diff --git a/src/service/api/routes.py b/src/service/api/routes.py index 5b7a8bc..e3d1a6c 100644 --- a/src/service/api/routes.py +++ b/src/service/api/routes.py @@ -8,19 +8,27 @@ # from aiohttp.web import Application -from service.api.views.api.bis import BiSView -from service.api.views.api.loot import LootView -from service.api.views.api.player import PlayerView -from service.api.views.html.bis import BiSHtmlView -from service.api.views.html.index import IndexHtmlView -from service.api.views.html.loot import LootHtmlView -from service.api.views.html.loot_suggest import LootSuggestHtmlView -from service.api.views.html.player import PlayerHtmlView -from service.api.views.html.static import StaticHtmlView +from .views.api.bis import BiSView +from .views.api.login import LoginView +from .views.api.logout import LogoutView +from .views.api.loot import LootView +from .views.api.player import PlayerView +from .views.html.bis import BiSHtmlView +from .views.html.index import IndexHtmlView +from .views.html.loot import LootHtmlView +from .views.html.loot_suggest import LootSuggestHtmlView +from .views.html.player import PlayerHtmlView +from .views.html.static import StaticHtmlView +from .views.html.users import UsersHtmlView def setup_routes(app: Application) -> None: # api routes + app.router.add_delete('/api/v1/login/{username}', LoginView) + app.router.add_post('/api/v1/login', LoginView) + app.router.add_post('/api/v1/logout', LogoutView) + app.router.add_put('/api/v1/login', LoginView) + app.router.add_get('/api/v1/party', PlayerView) app.router.add_post('/api/v1/party', PlayerView) @@ -47,3 +55,8 @@ def setup_routes(app: Application) -> None: app.router.add_get('/suggest', LootSuggestHtmlView) app.router.add_post('/suggest', LootSuggestHtmlView) + + app.router.add_get('/admin/users', UsersHtmlView) + app.router.add_post('/admin/users', UsersHtmlView) + + diff --git a/src/service/api/utils.py b/src/service/api/utils.py index e7efeec..26ed564 100644 --- a/src/service/api/utils.py +++ b/src/service/api/utils.py @@ -8,7 +8,7 @@ # import json -from aiohttp.web import Response +from aiohttp.web import HTTPException, Response from typing import Any, Mapping, List from .json import HttpEncoder @@ -23,6 +23,8 @@ def make_json(response: Any, args: Mapping[str, Any], code: int = 200) -> str: def wrap_exception(exception: Exception, args: Mapping[str, Any], code: int = 500) -> Response: + if isinstance(exception, HTTPException): + raise exception # reraise return return wrap_json({'message': repr(exception)}, args, code) diff --git a/src/service/api/views/api/login.py b/src/service/api/views/api/login.py new file mode 100644 index 0000000..2e7a44d --- /dev/null +++ b/src/service/api/views/api/login.py @@ -0,0 +1,62 @@ +# +# Copyright (c) 2019 Evgeniy Alekseev. +# +# This file is part of ffxivbis +# (see https://github.com/arcan1s/ffxivbis). +# +# License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause +# +from aiohttp.web import Response + +from service.api.utils import wrap_exception, wrap_invalid_param, wrap_json +from service.api.views.common.login_base import LoginBaseView + + +class LoginView(LoginBaseView): + + async def delete(self) -> Response: + username = self.request.match_info['username'] + + try: + await self.remove_user(username) + except Exception as e: + self.request.app.logger.exception('cannot remove user') + return wrap_exception(e, {'username': username}) + + return wrap_json({}, {'username': username}) + + async def post(self) -> Response: + try: + data = await self.request.json() + except Exception: + data = dict(await self.request.post()) + + required = ['username', 'password'] + if any(param not in data for param in required): + return wrap_invalid_param(required, data) + + try: + await self.login(data['username'], data['password']) + except Exception as e: + self.request.app.logger.exception('cannot login user') + return wrap_exception(e, data) + + return wrap_json({}, data) + + async def put(self) -> Response: + try: + data = await self.request.json() + except Exception: + data = dict(await self.request.post()) + + required = ['username', 'password'] + if any(param not in data for param in required): + return wrap_invalid_param(required, data) + + try: + await self.create_user(data['username'], data['password'], data.get('permission', 'get')) + except Exception as e: + self.request.app.logger.exception('cannot login user') + return wrap_exception(e, data) + + return wrap_json({}, data) \ No newline at end of file diff --git a/src/service/api/views/api/logout.py b/src/service/api/views/api/logout.py new file mode 100644 index 0000000..1bc5cea --- /dev/null +++ b/src/service/api/views/api/logout.py @@ -0,0 +1,21 @@ +# This file is part of ffxivbis +# (see https://github.com/arcan1s/ffxivbis). +# +# License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause +# +from aiohttp.web import Response + +from service.api.utils import wrap_exception, wrap_json +from service.api.views.common.login_base import LoginBaseView + + +class LogoutView(LoginBaseView): + + async def post(self) -> Response: + try: + await self.logout() + except Exception as e: + self.request.app.logger.exception('cannot logout user') + return wrap_exception(e, {}) + + return wrap_json({}, {}) \ No newline at end of file diff --git a/src/service/api/views/common/login_base.py b/src/service/api/views/common/login_base.py new file mode 100644 index 0000000..ef3f3f3 --- /dev/null +++ b/src/service/api/views/common/login_base.py @@ -0,0 +1,43 @@ +# +# Copyright (c) 2019 Evgeniy Alekseev. +# +# This file is part of ffxivbis +# (see https://github.com/arcan1s/ffxivbis). +# +# License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause +# +from aiohttp.web import HTTPFound, HTTPUnauthorized, View +from aiohttp_security import check_authorized, forget, remember +from passlib.hash import md5_crypt + +from service.models.user import User + + +class LoginBaseView(View): + + async def check_credentials(self, username: str, password: str) -> bool: + user = self.request.app['database'].get_user(username) + if user is None: + return False + return md5_crypt.verify(password, user.password) + + async def create_user(self, username: str, password: str, permission: str) -> None: + self.request.app['database'].insert_user(User(username, password, permission), False) + + async def login(self, username: str, password: str) -> None: + if await self.check_credentials(username, password): + response = HTTPFound('/') + await remember(self.request, response, username) + raise response + + raise HTTPUnauthorized() + + async def logout(self) -> None: + await check_authorized(self.request) + response = HTTPFound('/') + await forget(self.request, response) + + raise response + + async def remove_user(self, username: str) -> None: + self.request.app['database'].delete_user(username) \ No newline at end of file diff --git a/src/service/api/views/html/index.py b/src/service/api/views/html/index.py index 76a73b0..e3ec325 100644 --- a/src/service/api/views/html/index.py +++ b/src/service/api/views/html/index.py @@ -8,6 +8,7 @@ # from aiohttp.web import View from aiohttp_jinja2 import template +from aiohttp_security import authorized_userid from typing import Any, Dict @@ -15,4 +16,8 @@ class IndexHtmlView(View): @template('index.jinja2') async def get(self) -> Dict[str, Any]: - return {} + username = await authorized_userid(self.request) + + return { + 'logged': username + } diff --git a/src/service/api/views/html/loot_suggest.py b/src/service/api/views/html/loot_suggest.py index 1d956b1..535e77b 100644 --- a/src/service/api/views/html/loot_suggest.py +++ b/src/service/api/views/html/loot_suggest.py @@ -39,9 +39,9 @@ class LootSuggestHtmlView(LootBaseView, PlayerBaseView): return wrap_invalid_param(required, data) try: - piece = Piece.get({'piece': data.get('piece'), 'is_tome': data.get('is_tome', False)}) + piece = Piece.get({'piece': data.get('piece'), 'is_tome': data.get('is_tome', True)}) players = self.loot_put(piece) - item_values = {'piece': piece.name, 'is_tome': piece.is_tome} + item_values = {'piece': piece.name, 'is_tome': getattr(piece, 'is_tome', True)} except Exception as e: self.request.app.logger.exception('could not manage loot') diff --git a/src/service/api/views/html/users.py b/src/service/api/views/html/users.py new file mode 100644 index 0000000..f7268fd --- /dev/null +++ b/src/service/api/views/html/users.py @@ -0,0 +1,62 @@ +# +# Copyright (c) 2019 Evgeniy Alekseev. +# +# This file is part of ffxivbis +# (see https://github.com/arcan1s/ffxivbis). +# +# License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause +# +from aiohttp.web import HTTPFound, Response +from aiohttp_jinja2 import template +from typing import Any, Dict, List + +from service.models.user import User + +from service.api.utils import wrap_exception, wrap_invalid_param +from service.api.views.common.login_base import LoginBaseView + + +class UsersHtmlView(LoginBaseView): + + @template('users.jinja2') + async def get(self) -> Dict[str, Any]: + error = None + users: List[User] = [] + + try: + users = self.request.app['database'].get_users() + except Exception as e: + self.request.app.logger.exception('could not get users') + error = repr(e) + + return { + 'request_error': error, + 'users': users + } + + async def post(self) -> Response: + data = await self.request.post() + + required = ['action', 'username'] + if any(param not in data for param in required): + return wrap_invalid_param(required, data) + + try: + action = data.get('action') + username = data.get('username') + + if action == 'add': + required = ['password', 'permission'] + if any(param not in data for param in required): + return wrap_invalid_param(required, data) + await self.create_user(username, data.get('password'), data.get('permission')) + elif action == 'remove': + await self.remove_user(username) + else: + return wrap_invalid_param(['action'], data) + + except Exception as e: + self.request.app.logger.exception('could not manage users') + return wrap_exception(e, data) + + return HTTPFound(self.request.url) \ No newline at end of file diff --git a/src/service/api/web.py b/src/service/api/web.py index 6fc5c46..7795131 100644 --- a/src/service/api/web.py +++ b/src/service/api/web.py @@ -11,12 +11,15 @@ import jinja2 import logging from aiohttp import web +from aiohttp_security import setup as setup_security +from aiohttp_security import CookiesIdentityPolicy from service.core.config import Configuration from service.core.database import Database from service.core.loot_selector import LootSelector from service.core.party import Party +from .auth import AuthorizationPolicy, authorize_factory from .routes import setup_routes @@ -37,6 +40,12 @@ def setup_service(config: Configuration, database: Database, loot: LootSelector, app.middlewares.append(web.normalize_path_middleware(append_slash=False, remove_slash=True)) + # auth related + auth_required = config.getboolean('auth', 'enabled') + if auth_required: + setup_security(app, CookiesIdentityPolicy(), AuthorizationPolicy(database)) + app.middlewares.append(authorize_factory()) + # routes app.logger.info('setup routes') setup_routes(app) diff --git a/src/service/application/core.py b/src/service/application/core.py index 5e42a90..35465b4 100644 --- a/src/service/application/core.py +++ b/src/service/application/core.py @@ -13,6 +13,7 @@ from service.core.config import Configuration from service.core.database import Database from service.core.loot_selector import LootSelector from service.core.party import Party +from service.models.user import User class Application: @@ -26,6 +27,9 @@ class Application: database.migration() party = Party.get(database) + admin = User(self.config.get('auth', 'root_username'), self.config.get('auth', 'root_password'), 'admin') + database.insert_user(admin, True) + priority = self.config.get('settings', 'priority').split() loot_selector = LootSelector(party, priority) diff --git a/src/service/core/database.py b/src/service/core/database.py index f7748a3..e8a65dd 100644 --- a/src/service/core/database.py +++ b/src/service/core/database.py @@ -18,6 +18,7 @@ from service.models.loot import Loot from service.models.piece import Piece from service.models.player import Player, PlayerId from service.models.upgrade import Upgrade +from service.models.user import User from .config import Configuration from .exceptions import InvalidDatabase @@ -59,12 +60,21 @@ class Database: def delete_player(self, player_id: PlayerId) -> None: raise NotImplementedError + def delete_user(self, username: str) -> None: + raise NotImplementedError + def get_party(self) -> List[Player]: raise NotImplementedError def get_player(self, player_id: PlayerId) -> Optional[int]: raise NotImplementedError + def get_user(self, username: str) -> Optional[User]: + raise NotImplementedError + + def get_users(self) -> List[User]: + raise NotImplementedError + def insert_piece(self, player_id: PlayerId, piece: Union[Piece, Upgrade]) -> None: raise NotImplementedError @@ -74,6 +84,9 @@ class Database: def insert_player(self, player: Player) -> None: raise NotImplementedError + def insert_user(self, user: User, hashed_password: bool) -> None: + raise NotImplementedError + def migration(self) -> None: self.logger.info('perform migrations at {}'.format(self.connection)) backend = get_backend(self.connection) diff --git a/src/service/core/sqlite.py b/src/service/core/sqlite.py index ad9fc7f..4bffba8 100644 --- a/src/service/core/sqlite.py +++ b/src/service/core/sqlite.py @@ -6,6 +6,7 @@ # # License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause # +from passlib.hash import md5_crypt from typing import List, Optional, Union from service.models.bis import BiS @@ -14,6 +15,7 @@ from service.models.loot import Loot from service.models.piece import Piece from service.models.player import Player, PlayerId from service.models.upgrade import Upgrade +from service.models.user import User from .database import Database from .sqlite_helper import SQLiteHelper @@ -58,6 +60,10 @@ class SQLiteDatabase(Database): cursor.execute('''delete from players where nick = ? and job = ?''', (player_id.nick, player_id.job.name)) + def delete_user(self, username: str) -> None: + with SQLiteHelper(self.database_path) as cursor: + cursor.execute('''delete from users where username = ?''', (username,)) + def get_party(self) -> List[Player]: with SQLiteHelper(self.database_path) as cursor: cursor.execute('''select * from bis''') @@ -78,6 +84,17 @@ class SQLiteDatabase(Database): player = cursor.fetchone() return player['player_id'] if player is not None else None + def get_user(self, username: str) -> Optional[User]: + with SQLiteHelper(self.database_path) as cursor: + cursor.execute('''select * from users where username = ?''', (username,)) + user = cursor.fetchone() + return User(user['username'], user['password'], user['permission']) if user is not None else None + + def get_users(self) -> List[User]: + with SQLiteHelper(self.database_path) as cursor: + cursor.execute('''select * from users''') + return [User(user['username'], user['password'], user['permission']) for user in cursor.fetchall()] + def insert_piece(self, player_id: PlayerId, piece: Union[Piece, Upgrade]) -> None: player = self.get_player(player_id) if player is None: @@ -114,4 +131,15 @@ class SQLiteDatabase(Database): values (?, ?, ?, ?, ?)''', (Database.now(), player.nick, player.job.name, player.link, player.priority) + ) + + def insert_user(self, user: User, hashed_password: bool) -> None: + password = user.password if hashed_password else md5_crypt.hash(user.password) + with SQLiteHelper(self.database_path) as cursor: + cursor.execute( + '''replace into users + (username, password, permission) + values + (?, ?, ?)''', + (user.username, password, user.permission) ) \ No newline at end of file diff --git a/src/service/models/user.py b/src/service/models/user.py new file mode 100644 index 0000000..72713e9 --- /dev/null +++ b/src/service/models/user.py @@ -0,0 +1,16 @@ +# +# Copyright (c) 2019 Evgeniy Alekseev. +# +# This file is part of ffxivbis +# (see https://github.com/arcan1s/ffxivbis). +# +# License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause +# +from dataclasses import dataclass + + +@dataclass +class User: + username: str + password: str + permission: str \ No newline at end of file diff --git a/templates/index.jinja2 b/templates/index.jinja2 index 79fc00f..d261bfe 100644 --- a/templates/index.jinja2 +++ b/templates/index.jinja2 @@ -8,10 +8,27 @@