Skip to content

Commit

Permalink
DB, 2FA TOTP, technical
Browse files Browse the repository at this point in the history
Add the new Python dependency:
    pip install pyotp

Update the database (sof/4897867):
    PRAGMA foreign_keys=OFF;
    ALTER TABLE users RENAME TO tmp_users;
    CREATE TABLE users ("user" TEXT NOT NULL, "password" TEXT NOT NULL, "initial" TEXT NOT NULL CHECK("initial" IN ('', 'X')), "totp" TEXT NOT NULL, "password_date" TEXT NOT NULL, "password_time" TEXT NOT NULL, PRIMARY KEY("user"));
    INSERT INTO users (user, password, initial, totp, password_date, password_time) SELECT user, password, initial, '', password_date, password_time FROM tmp_users;
    DROP TABLE tmp_users;
    ALTER TABLE users RENAME TO tmp_users;
    ALTER TABLE tmp_users RENAME TO users;
    PRAGMA foreign_keys=ON;
  • Loading branch information
gitbra committed Sep 11, 2023
1 parent 2211150 commit 99f7259
Show file tree
Hide file tree
Showing 18 changed files with 305 additions and 104 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ The [official homepage](https://pwic.wiki) is running the latest version.
- Multi-projects with dedicated authorizations by user
- Global and project-dependent settings
- OAuth2-based federated authentication with control of the state (SSO)
- Two-factor authentication based on time (2FA TOTP)
- Cache system
- Private and public modes
- Custom CSS and templates
Expand Down
Binary file modified locale/de/LC_MESSAGES/pwic.mo
Binary file not shown.
12 changes: 9 additions & 3 deletions locale/de/LC_MESSAGES/pwic.po
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,9 @@ msgstr "Benutzer:"
msgid "Password:"
msgstr "Kennwort:"

msgid "Optional PIN code for 2FA:"
msgstr "Optionaler PIN für 2FA:"

msgid "Language:"
msgstr "Sprache:"

Expand Down Expand Up @@ -728,8 +731,8 @@ msgstr "Wählen Sie Ihr Projekt"
msgid "Unfortunately, you have access to no project at all."
msgstr "Sie haben Leider keinen Zugriff zu einem Projekt."

msgid "Projects you joined already"
msgstr "Registrierte Projekte"
msgid "Joined projects"
msgstr "Projekte"

msgid "Description"
msgstr "Beschreibung"
Expand Down Expand Up @@ -849,7 +852,10 @@ msgstr "Profil"
msgid "Own password:"
msgstr "Eigenes Kennwort:"

msgid "Federated authentification only"
msgid "Enabled two-factor authentication (2FA)"
msgstr "Aktivierte Zwei-Faktor-Authentifizierung (2FA)"

msgid "Federated authentication only"
msgstr "Nur föderierte Authentifizierung"

msgid "change it"
Expand Down
Binary file modified locale/fr/LC_MESSAGES/pwic.mo
Binary file not shown.
12 changes: 9 additions & 3 deletions locale/fr/LC_MESSAGES/pwic.po
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,9 @@ msgstr "Utilisateur :"
msgid "Password:"
msgstr "Mot de passe :"

msgid "Optional PIN code for 2FA:"
msgstr "Code PIN optionnel pour 2FA :"

msgid "Language:"
msgstr "Langue :"

Expand Down Expand Up @@ -728,8 +731,8 @@ msgstr "Sélectionnez votre projet"
msgid "Unfortunately, you have access to no project at all."
msgstr "Malheureusement, vous n'avez accès à aucun projet."

msgid "Projects you joined already"
msgstr "Projets déjà rejoints"
msgid "Joined projects"
msgstr "Projets rejoints"

msgid "Description"
msgstr "Description"
Expand Down Expand Up @@ -849,7 +852,10 @@ msgstr "Profil"
msgid "Own password:"
msgstr "Mot de passe personnalisé :"

msgid "Federated authentification only"
msgid "Enabled two-factor authentication (2FA)"
msgstr "Double authentification activée (2FA)"

msgid "Federated authentication only"
msgstr "Authentification fédérée seulement"

msgid "change it"
Expand Down
10 changes: 8 additions & 2 deletions locale/pwic.pot
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,9 @@ msgstr ""
msgid "Password:"
msgstr ""

msgid "Optional PIN code for 2FA:"
msgstr ""

msgid "Language:"
msgstr ""

Expand Down Expand Up @@ -713,7 +716,7 @@ msgstr ""
msgid "Unfortunately, you have access to no project at all."
msgstr ""

msgid "Projects you joined already"
msgid "Joined projects"
msgstr ""

msgid "Description"
Expand Down Expand Up @@ -834,7 +837,10 @@ msgstr ""
msgid "Own password:"
msgstr ""

msgid "Federated authentification only"
msgid "Enabled two-factor authentication (2FA)"
msgstr ""

msgid "Federated authentication only"
msgstr ""

msgid "change it"
Expand Down
86 changes: 56 additions & 30 deletions pwic.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
from aiohttp import web, MultipartReader, hdrs
from aiohttp_session import setup, get_session, new_session, Session
from aiohttp_session.cookie_storage import EncryptedCookieStorage
import pyotp

from pwic_md import Markdown
from pwic_lib import PwicConst, PwicLib, PwicError
Expand Down Expand Up @@ -1343,13 +1344,17 @@ async def page_user(self, request: web.Request) -> web.Response:
# Fetch the information of the user
sql = self.dbconn.cursor()
userpage = PwicLib.safe_user_name(request.match_info.get('userpage'))
row = sql.execute(''' SELECT password, initial FROM users WHERE user = ?''', (userpage, )).fetchone()
row = sql.execute(''' SELECT IIF(password == ?, 'X', '') AS oauth, initial, IIF(totp <> '', 'X', '') AS totp
FROM users
WHERE user = ?''',
(PwicConst.MAGIC_OAUTH, userpage)).fetchone()
if row is None:
raise web.HTTPNotFound()
pwic = {'user': user,
'userpage': userpage,
'password_oauth': row['password'] == PwicConst.MAGIC_OAUTH,
'password_initial': row['initial']}
'password_oauth': PwicLib.xb(row['oauth']),
'password_initial': row['initial'],
'password_totp': PwicLib.xb(row['totp'])}

# Fetch the commonly-accessible projects assigned to the user
sql.execute(''' SELECT a.project, c.description
Expand Down Expand Up @@ -1388,7 +1393,8 @@ async def page_user(self, request: web.Request) -> web.Response:
pwic['documents'] = sql.fetchall()
for row in pwic['documents']:
row['mime_icon'] = PwicLib.mime2icon(row['mime'])
row['size'] = PwicLib.size2str(row['size'])
row['extension'] = PwicLib.file_ext(row['filename'])
row['size_str'] = PwicLib.size2str(row['size'])

# Fetch the latest pages updated by the selected user
dt = PwicLib.dt()
Expand Down Expand Up @@ -1913,7 +1919,7 @@ async def document_all_get(self, request: web.Request) -> web.Response:
# Read the properties of the requested document
project = PwicLib.safe_name(request.match_info.get('project'))
page = PwicLib.safe_name(request.match_info.get('page'))
if '' in [project, page]:
if (project in PwicConst.NOT_PROJECT) or (page in PwicConst.NOT_PAGE):
raise web.HTTPBadRequest()

# Fetch the documents
Expand Down Expand Up @@ -1994,21 +2000,31 @@ async def api_login(self, request: web.Request) -> web.Response:
post = await self._handle_post(request)
user = PwicLib.safe_user_name(post.get('user'))
pwd = '' if user == PwicConst.USERS['anonymous'] else PwicLib.sha256(post.get('password', ''))
pin = PwicLib.intval(post.get('pin'))
lang = post.get('language', session.get('language', ''))
if lang not in app['langs']:
lang = PwicConst.DEFAULTS['language']

# Login with the credentials
ok = False
ok_pwd = False
ok_totp = True
sql = self.dbconn.cursor()
sql.execute(''' SELECT 1
FROM users
WHERE user = ?
AND password = ?''',
(user, pwd))
if sql.fetchone() is not None:
ok = PwicExtension.on_login(sql, request, user, lang, ip)
if ok:
row = sql.execute(''' SELECT totp
FROM users
WHERE user = ?
AND password = ?''',
(user, pwd)).fetchone()
if row is not None:
# 2FA TOTP and custom checks
if (row['totp'] != '') and (PwicLib.option(sql, '', 'no_totp') is None):
if not pyotp.TOTP(row['totp']).verify(str(pin)):
ok_totp = False
del row['totp']
if ok_totp:
ok_pwd = PwicExtension.on_login(sql, request, user, lang, ip)

# Open the session
if ok_pwd:
self._auto_join(sql, request, user, ['active'])
session = await new_session(request)
session['user'] = user
Expand All @@ -2024,8 +2040,10 @@ async def api_login(self, request: web.Request) -> web.Response:

# Final redirection (do not use "raise")
if 'redirect' in request.rel_url.query:
return web.HTTPFound('/' if ok else '/?failed')
return web.HTTPOk() if ok else web.HTTPUnauthorized()
return web.HTTPFound('/' if ok_pwd and ok_totp else '/special/login?failed')
if not ok_totp:
return web.HTTPRequestTimeout()
return web.HTTPOk() if ok_pwd else web.HTTPUnauthorized()

async def api_oauth(self, request: web.Request) -> web.Response:
''' Manage the federated authentication '''
Expand Down Expand Up @@ -2570,7 +2588,7 @@ async def api_project_progress_get(self, request: web.Request) -> web.Response:
project = PwicLib.safe_name(post.get('project'))
tags = PwicLib.list(PwicLib.list_tags(post.get('tags', '')))
combined = PwicLib.xb(PwicLib.x(post.get('combined')))
if '' in [project, tags]:
if (project in PwicConst.NOT_PROJECT) or (len(tags) == 0):
raise web.HTTPBadRequest()

# Verify that the user is authorized for the project
Expand Down Expand Up @@ -2701,7 +2719,7 @@ def _get_node_id(project: str, page: str) -> str:
subpages = PwicConst.REGEXES['page'].findall(row['markdown'].replace(app['options']['base_url'], ''))
if subpages is not None:
for sp in subpages:
if sp[0] in PwicConst.NOT_PROJECT:
if (sp[0] in PwicConst.NOT_PROJECT) or (sp[1] in PwicConst.NOT_PAGE):
continue
_get_node_id(sp[0], sp[1])
_make_link(row['project'], row['page'], sp[0], sp[1])
Expand Down Expand Up @@ -2797,7 +2815,7 @@ async def api_page_create(self, request: web.Request) -> web.Response:
ref_page = PwicLib.safe_name(post.get('ref_page'))
ref_tags = PwicLib.xb(PwicLib.x(post.get('ref_tags')))
if (((project in PwicConst.NOT_PROJECT)
or (not kb and (page in ['', 'special']))
or (not kb and (page in PwicConst.NOT_PAGE))
or ((ref_page != '') and (ref_project == '')))):
raise web.HTTPBadRequest()

Expand Down Expand Up @@ -2922,7 +2940,7 @@ async def api_page_edit(self, request: web.Request) -> None:
protection = PwicLib.xb(PwicLib.x(post.get('protection')))
no_quick_fix = PwicLib.xb(PwicLib.x(post.get('no_quick_fix')))
dt = PwicLib.dt()
if '' in [user, project, page, title, comment]:
if (project in PwicConst.NOT_PROJECT) or (page in PwicConst.NOT_PAGE) or ('' in [user, title, comment]):
raise web.HTTPBadRequest()

# Check the maximal size of a revision
Expand Down Expand Up @@ -3095,7 +3113,7 @@ async def api_page_validate(self, request: web.Request) -> None:
project = PwicLib.safe_name(post.get('project'))
page = PwicLib.safe_name(post.get('page'))
revision = PwicLib.intval(post.get('revision', 0))
if ('' in [project, page]) or (revision == 0):
if (project in PwicConst.NOT_PROJECT) or (page in PwicConst.NOT_PAGE) or (revision == 0):
raise web.HTTPBadRequest()

# Verify that it is possible to validate the page
Expand Down Expand Up @@ -3159,7 +3177,10 @@ async def api_page_move(self, request: web.Request) -> web.Response:
ignore_file_errors = PwicLib.xb(PwicLib.x(post.get('ignore_file_errors', 'X')))
if dstpage == '':
dstpage = srcpage
if '' in [srcproj, srcpage, dstproj, dstpage]:
if (((srcproj in PwicConst.NOT_PROJECT)
or (srcpage in PwicConst.NOT_PAGE)
or (dstproj in PwicConst.NOT_PROJECT)
or (dstpage in PwicConst.NOT_PAGE))):
raise web.HTTPBadRequest()

# Verify that the user is a manager of the 2 projects (no need to check the protection of the page)
Expand Down Expand Up @@ -3298,7 +3319,7 @@ async def api_page_delete(self, request: web.Request) -> None:
project = PwicLib.safe_name(post.get('project'))
page = PwicLib.safe_name(post.get('page'))
revision = PwicLib.intval(post.get('revision', 0))
if ('' in [project, page]) or (revision == 0):
if (project in PwicConst.NOT_PROJECT) or (page in PwicConst.NOT_PAGE) or (revision == 0):
raise web.HTTPBadRequest()

# Verify that the deletion is possible
Expand Down Expand Up @@ -3433,7 +3454,7 @@ async def api_page_export(self, request: web.Request) -> web.Response:
page = PwicLib.safe_name(post.get('page'))
revision = PwicLib.intval(post.get('revision', 0))
extension = post.get('format', '').strip().lower()
if '' in [project, page, extension]:
if (project in PwicConst.NOT_PROJECT) or (page in PwicConst.NOT_PAGE) or (extension == ''):
raise web.HTTPBadRequest()

# Apply the options on the parameters
Expand Down Expand Up @@ -3511,7 +3532,7 @@ async def api_user_create(self, request: web.Request) -> None:
project = PwicLib.safe_name(post.get('project'))
wisheduser = post.get('user', '').strip().lower()
newuser = PwicLib.safe_user_name(post.get('user'))
if (wisheduser != newuser) or ('' in [project, newuser]) or (newuser[:4] == 'pwic'):
if (project in PwicConst.NOT_PROJECT) or (wisheduser != newuser) or (newuser[:4] in ['', 'pwic']):
raise web.HTTPBadRequest()

# Verify that the user is administrator and has changed his password
Expand Down Expand Up @@ -3655,7 +3676,7 @@ async def api_user_roles_set(self, request: web.Request) -> web.Response:
delete = roles[roleid] == 'delete'
except ValueError as e:
raise web.HTTPBadRequest() from e
if '' in [project, userpost] or ((userpost[:4] == 'pwic') and (roles in ['admin', 'delete'])):
if (project in PwicConst.NOT_PROJECT) or (userpost == '') or ((userpost[:4] == 'pwic') and (roles in ['admin', 'delete'])):
raise web.HTTPBadRequest()

# Select the current rights of the user
Expand Down Expand Up @@ -4007,7 +4028,7 @@ async def api_document_list(self, request: web.Request) -> web.Response:
post = await self._handle_post(request)
project = PwicLib.safe_name(post.get('project'))
page = PwicLib.safe_name(post.get('page'))
if '' in [project, page]:
if (project in PwicConst.NOT_PROJECT) or (page in PwicConst.NOT_PAGE):
raise web.HTTPBadRequest()

# Read the documents
Expand Down Expand Up @@ -4368,7 +4389,9 @@ async def api_odata_content(self, request: web.Request) -> web.Response:
AND b.disabled = '' ''',
(user, ))
elif table == 'users':
sql.execute(''' SELECT DISTINCT a.user, a.initial, a.password_date, a.password_time
sql.execute(''' SELECT DISTINCT a.user, IIF(a.password == ?, 'X', '') AS oauth,
a.initial, IIF(a.totp <> '', 'X', '') AS totp,
a.password_date, a.password_time
FROM users AS a
INNER JOIN roles AS b
ON b.user = a.user
Expand All @@ -4381,10 +4404,10 @@ async def api_odata_content(self, request: web.Request) -> web.Response:
) AS c
ON c.project = b.project
UNION
SELECT user, initial, password_date, password_time
SELECT user, '' AS oauth, initial, '' AS totp, password_date, password_time
FROM users
WHERE user = '' ''',
(user, ))
(PwicConst.MAGIC_OAUTH, user))
elif table == 'roles':
sql.execute(''' SELECT a.project, a.user, a.admin, a.manager, a.editor,
a.validator, a.reader
Expand All @@ -4411,6 +4434,9 @@ async def api_odata_content(self, request: web.Request) -> web.Response:
elif table == 'pages':
row['valdate'] = row['valdate'] or '1970-01-01'
row['valtime'] = row['valtime'] or '00:00:00'
elif table == 'users':
row['oauth'] = PwicLib.xb(row['oauth'])
row['totp'] = PwicLib.xb(row['totp'])
data['value'].append(row)

# Result
Expand Down
Loading

0 comments on commit 99f7259

Please sign in to comment.