auth support

This commit is contained in:
Evgenii Alekseev 2019-09-11 02:51:55 +03:00
parent 39be8cebf1
commit 7de6a5fcc9
21 changed files with 471 additions and 14 deletions

View File

@ -71,6 +71,34 @@ Service which allows to manage savage loot distribution easy.
* `is_tome`: is item tome gear or not, bool, required. * `is_tome`: is item tome gear or not, bool, required.
* `piece`: item name, string, 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 ## Configuration
@ -93,6 +121,14 @@ Service which allows to manage savage loot distribution easy.
* `xivapi_key`: xivapi developer key, string, optional. * `xivapi_key`: xivapi developer key, string, optional.
* `xivapi_url`: xivapi base url, string, required. * `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 * `sqlite` section
Database settings for `sqlite` provider. Database settings for `sqlite` provider.

View File

@ -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)''')
]

View File

@ -7,3 +7,4 @@ priority = is_required loot_count_bis loot_count_total loot_count loot_priority
[web] [web]
host = 0.0.0.0 host = 0.0.0.0
port = 8000 port = 8000
templates = templates

View File

@ -0,0 +1,4 @@
[auth]
enabled = yes
root_username = admin
root_password = $1$R3j4sym6$HtvrKOJ66f7w3.9Zc3U6h1

View File

@ -25,9 +25,11 @@ setup(
packages=find_packages(exclude=['contrib', 'docs', 'tests']), packages=find_packages(exclude=['contrib', 'docs', 'tests']),
install_requires=[ install_requires=[
'aiohttp_jinja2',
'aiohttp', 'aiohttp',
'aiohttp_jinja2',
'aiohttp_security',
'Jinja2', 'Jinja2',
'passlib',
'requests', 'requests',
'yoyo_migrations' 'yoyo_migrations'
], ],

51
src/service/api/auth.py Normal file
View 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

View File

@ -8,19 +8,27 @@
# #
from aiohttp.web import Application from aiohttp.web import Application
from service.api.views.api.bis import BiSView from .views.api.bis import BiSView
from service.api.views.api.loot import LootView from .views.api.login import LoginView
from service.api.views.api.player import PlayerView from .views.api.logout import LogoutView
from service.api.views.html.bis import BiSHtmlView from .views.api.loot import LootView
from service.api.views.html.index import IndexHtmlView from .views.api.player import PlayerView
from service.api.views.html.loot import LootHtmlView from .views.html.bis import BiSHtmlView
from service.api.views.html.loot_suggest import LootSuggestHtmlView from .views.html.index import IndexHtmlView
from service.api.views.html.player import PlayerHtmlView from .views.html.loot import LootHtmlView
from service.api.views.html.static import StaticHtmlView 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: def setup_routes(app: Application) -> None:
# api routes # 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_get('/api/v1/party', PlayerView)
app.router.add_post('/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_get('/suggest', LootSuggestHtmlView)
app.router.add_post('/suggest', LootSuggestHtmlView) app.router.add_post('/suggest', LootSuggestHtmlView)
app.router.add_get('/admin/users', UsersHtmlView)
app.router.add_post('/admin/users', UsersHtmlView)

View File

@ -8,7 +8,7 @@
# #
import json import json
from aiohttp.web import Response from aiohttp.web import HTTPException, Response
from typing import Any, Mapping, List from typing import Any, Mapping, List
from .json import HttpEncoder 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: 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) return wrap_json({'message': repr(exception)}, args, code)

View 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)

View 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({}, {})

View 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)

View File

@ -8,6 +8,7 @@
# #
from aiohttp.web import View from aiohttp.web import View
from aiohttp_jinja2 import template from aiohttp_jinja2 import template
from aiohttp_security import authorized_userid
from typing import Any, Dict from typing import Any, Dict
@ -15,4 +16,8 @@ class IndexHtmlView(View):
@template('index.jinja2') @template('index.jinja2')
async def get(self) -> Dict[str, Any]: async def get(self) -> Dict[str, Any]:
return {} username = await authorized_userid(self.request)
return {
'logged': username
}

View File

@ -39,9 +39,9 @@ class LootSuggestHtmlView(LootBaseView, PlayerBaseView):
return wrap_invalid_param(required, data) return wrap_invalid_param(required, data)
try: 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) 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: except Exception as e:
self.request.app.logger.exception('could not manage loot') self.request.app.logger.exception('could not manage loot')

View 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)

View File

@ -11,12 +11,15 @@ import jinja2
import logging import logging
from aiohttp import web 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.config import Configuration
from service.core.database import Database from service.core.database import Database
from service.core.loot_selector import LootSelector from service.core.loot_selector import LootSelector
from service.core.party import Party from service.core.party import Party
from .auth import AuthorizationPolicy, authorize_factory
from .routes import setup_routes 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)) 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 # routes
app.logger.info('setup routes') app.logger.info('setup routes')
setup_routes(app) setup_routes(app)

View File

@ -13,6 +13,7 @@ from service.core.config import Configuration
from service.core.database import Database from service.core.database import Database
from service.core.loot_selector import LootSelector from service.core.loot_selector import LootSelector
from service.core.party import Party from service.core.party import Party
from service.models.user import User
class Application: class Application:
@ -26,6 +27,9 @@ class Application:
database.migration() database.migration()
party = Party.get(database) 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() priority = self.config.get('settings', 'priority').split()
loot_selector = LootSelector(party, priority) loot_selector = LootSelector(party, priority)

View File

@ -18,6 +18,7 @@ from service.models.loot import Loot
from service.models.piece import Piece from service.models.piece import Piece
from service.models.player import Player, PlayerId from service.models.player import Player, PlayerId
from service.models.upgrade import Upgrade from service.models.upgrade import Upgrade
from service.models.user import User
from .config import Configuration from .config import Configuration
from .exceptions import InvalidDatabase from .exceptions import InvalidDatabase
@ -59,12 +60,21 @@ class Database:
def delete_player(self, player_id: PlayerId) -> None: def delete_player(self, player_id: PlayerId) -> None:
raise NotImplementedError raise NotImplementedError
def delete_user(self, username: str) -> None:
raise NotImplementedError
def get_party(self) -> List[Player]: def get_party(self) -> List[Player]:
raise NotImplementedError raise NotImplementedError
def get_player(self, player_id: PlayerId) -> Optional[int]: def get_player(self, player_id: PlayerId) -> Optional[int]:
raise NotImplementedError 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: def insert_piece(self, player_id: PlayerId, piece: Union[Piece, Upgrade]) -> None:
raise NotImplementedError raise NotImplementedError
@ -74,6 +84,9 @@ class Database:
def insert_player(self, player: Player) -> None: def insert_player(self, player: Player) -> None:
raise NotImplementedError raise NotImplementedError
def insert_user(self, user: User, hashed_password: bool) -> None:
raise NotImplementedError
def migration(self) -> None: def migration(self) -> None:
self.logger.info('perform migrations at {}'.format(self.connection)) self.logger.info('perform migrations at {}'.format(self.connection))
backend = get_backend(self.connection) backend = get_backend(self.connection)

View File

@ -6,6 +6,7 @@
# #
# License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause # 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 typing import List, Optional, Union
from service.models.bis import BiS 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.piece import Piece
from service.models.player import Player, PlayerId from service.models.player import Player, PlayerId
from service.models.upgrade import Upgrade from service.models.upgrade import Upgrade
from service.models.user import User
from .database import Database from .database import Database
from .sqlite_helper import SQLiteHelper from .sqlite_helper import SQLiteHelper
@ -58,6 +60,10 @@ class SQLiteDatabase(Database):
cursor.execute('''delete from players where nick = ? and job = ?''', cursor.execute('''delete from players where nick = ? and job = ?''',
(player_id.nick, player_id.job.name)) (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]: def get_party(self) -> List[Player]:
with SQLiteHelper(self.database_path) as cursor: with SQLiteHelper(self.database_path) as cursor:
cursor.execute('''select * from bis''') cursor.execute('''select * from bis''')
@ -78,6 +84,17 @@ class SQLiteDatabase(Database):
player = cursor.fetchone() player = cursor.fetchone()
return player['player_id'] if player is not None else None 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: def insert_piece(self, player_id: PlayerId, piece: Union[Piece, Upgrade]) -> None:
player = self.get_player(player_id) player = self.get_player(player_id)
if player is None: if player is None:
@ -114,4 +131,15 @@ class SQLiteDatabase(Database):
values values
(?, ?, ?, ?, ?)''', (?, ?, ?, ?, ?)''',
(Database.now(), player.nick, player.job.name, player.link, player.priority) (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)
) )

View 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

View File

@ -8,10 +8,27 @@
<body> <body>
<center> <center>
{% if logged is defined and logged %}
<form action="/api/v1/logout" method="post">
logged in as {{ logged|e }}
<input name="logout" id="logout" type="submit" value="logout"/>
</form>
{% else %}
<form action="/api/v1/login" method="post">
<input name="username" id="username" title="username" placeholder="username" type="text" required/>
<input name="password" id="password" title="password" placeholder="password" type="password" required/>
<input name="login" id="login" type="submit" value="login"/>
</form>
{% endif %}
<br>
<h2><a href="/party" title="party">party</a></h2> <h2><a href="/party" title="party">party</a></h2>
<h2><a href="/bis" title="bis management">bis</a></h2> <h2><a href="/bis" title="bis management">bis</a></h2>
<h2><a href="/loot" title="loot management">loot</a></h2> <h2><a href="/loot" title="loot management">loot</a></h2>
<h2><a href="/suggest" title="suggest loot">suggest</a></h2> <h2><a href="/suggest" title="suggest loot">suggest</a></h2>
<hr>
<h2><a href="/admin/users" title="manage users">manage users</a></h2>
</center> </center>
</body> </body>
</html> </html>

51
templates/users.jinja2 Normal file
View File

@ -0,0 +1,51 @@
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd">
<html lang="en">
<head>
<title>Users</title>
<link href="{{ static('styles.css') }}" rel="stylesheet" type="text/css">
</head>
<body>
<h2>users</h2>
{% include "error.jinja2" %}
{% include "search_line.jinja2" %}
<form action="/admin/users" method="post">
<input name="username" id="username" title="username" placeholder="username" type="text"/>
<input name="password" id="password" title="password" placeholder="password" type="password"/>
<select name="permission" id="permission" title="permission">
<option>get</option>
<option>post</option>
</select>
<input name="action" id="action" type="hidden" value="add"/>
<input name="add" id="add" type="submit" value="add"/>
</form>
<table id="result">
<tr>
<th>username</th>
<th>permission</th>
<th></th>
</tr>
{% for user in users %}
<tr>
<td class="include_search">{{ user.username|e }}</td>
<td>{{ user.permission|e }}</td>
<td>
<form action="/admin/users" method="post">
<input name="username" id="username" type="hidden" value="{{ user.username|e }}"/>
<input name="action" id="action" type="hidden" value="remove"/>
<input name="remove" id="remove" type="submit" value="x"/>
</form>
</td>
</tr>
{% endfor %}
</table>
{% include "export_to_csv.jinja2" %}
{% include "root.jinja2" %}
{% include "search.jinja2" %}
</body>
</html>