mirror of
https://github.com/arcan1s/ffxivbis.git
synced 2025-09-01 13:19:56 +00:00
auth support
This commit is contained in:
51
src/service/api/auth.py
Normal file
51
src/service/api/auth.py
Normal file
@ -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
|
||||
|
@ -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)
|
||||
|
||||
|
||||
|
@ -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)
|
||||
|
||||
|
||||
|
62
src/service/api/views/api/login.py
Normal file
62
src/service/api/views/api/login.py
Normal file
@ -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)
|
21
src/service/api/views/api/logout.py
Normal file
21
src/service/api/views/api/logout.py
Normal file
@ -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({}, {})
|
43
src/service/api/views/common/login_base.py
Normal file
43
src/service/api/views/common/login_base.py
Normal file
@ -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)
|
@ -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
|
||||
}
|
||||
|
@ -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')
|
||||
|
62
src/service/api/views/html/users.py
Normal file
62
src/service/api/views/html/users.py
Normal file
@ -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)
|
@ -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)
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
)
|
16
src/service/models/user.py
Normal file
16
src/service/models/user.py
Normal file
@ -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
|
Reference in New Issue
Block a user