This commit is contained in:
itskovacs 2025-10-28 18:18:48 +01:00
parent 4c8c505e5e
commit c0daaf5820
11 changed files with 214 additions and 12 deletions

View File

@ -91,6 +91,11 @@ class Token(BaseModel):
refresh_token: str refresh_token: str
class PendingTOTP(BaseModel):
pending_code: str
username: str
class ImageBase(SQLModel): class ImageBase(SQLModel):
filename: str filename: str

View File

@ -7,3 +7,4 @@ pydantic_settings~=2.9
Pillow~=11.2 Pillow~=11.2
authlib~=1.6 authlib~=1.6
alembic~=1.16 alembic~=1.16
pyotp~=2.9

View File

@ -5,12 +5,16 @@ from fastapi.responses import JSONResponse
from ..config import settings from ..config import settings
from ..db.core import init_user_data from ..db.core import init_user_data
from ..deps import SessionDep from ..deps import SessionDep
from ..models.models import AuthParams, LoginRegisterModel, Token, User from ..models.models import (AuthParams, LoginRegisterModel, PendingTOTP,
from ..security import (create_access_token, create_tokens, get_oidc_client, Token, User)
get_oidc_config, hash_password, verify_password) from ..security import (create_access_token, create_tokens,
generate_totp_secret, get_oidc_client, get_oidc_config,
hash_password, verify_password, verify_totp_code)
from ..utils.date import dt_utc, dt_utc_offset
from ..utils.utils import generate_filename from ..utils.utils import generate_filename
router = APIRouter(prefix="/api/auth", tags=["auth"]) router = APIRouter(prefix="/api/auth", tags=["auth"])
pending_totp_usernames = {}
@router.get("/params", response_model=AuthParams) @router.get("/params", response_model=AuthParams)
@ -104,8 +108,8 @@ async def oidc_login(
return create_tokens(data={"sub": username}) return create_tokens(data={"sub": username})
@router.post("/login", response_model=Token) @router.post("/login", response_model=Token | PendingTOTP)
def login(req: LoginRegisterModel, session: SessionDep) -> Token: def login(req: LoginRegisterModel, session: SessionDep) -> Token | PendingTOTP:
if settings.OIDC_CLIENT_ID or settings.OIDC_CLIENT_SECRET: if settings.OIDC_CLIENT_ID or settings.OIDC_CLIENT_SECRET:
raise HTTPException(status_code=400, detail="OIDC is configured") raise HTTPException(status_code=400, detail="OIDC is configured")
@ -113,9 +117,39 @@ def login(req: LoginRegisterModel, session: SessionDep) -> Token:
if not db_user or not verify_password(req.password, db_user.password): if not db_user or not verify_password(req.password, db_user.password):
raise HTTPException(status_code=401, detail="Invalid credentials") raise HTTPException(status_code=401, detail="Invalid credentials")
if db_user.totp_enabled:
pending_totp_secret = generate_totp_secret() # A random code to track for verify fn
pending_totp_usernames[db_user.username] = {
"pending_code": pending_totp_secret,
"exp": dt_utc_offset(5),
}
return {"pending_code": pending_totp_secret, "username": db_user.username}
return create_tokens(data={"sub": db_user.username}) return create_tokens(data={"sub": db_user.username})
@router.post("/login_totp", response_model=Token)
async def login_verify_totp(
session: SessionDep,
username: str = Body(..., embed=True),
pending_code: str = Body(..., embed=True),
code: str = Body(..., embed=True),
) -> Token:
user = session.get(User, username)
if not user or not user.totp_enabled:
raise HTTPException(status_code=401, detail="Invalid TOTP flow")
record = pending_totp_usernames.get(username)
if not record or record["exp"] < dt_utc() or record["pending_code"] != pending_code:
pending_totp_usernames.pop(username, None)
raise HTTPException(status_code=401, detail="Unauthorized")
if not verify_totp_code(user.totp_secret, code):
raise HTTPException(status_code=403, detail="Invalid TOTP code")
return create_tokens({"sub": user.username})
@router.post("/register", response_model=Token) @router.post("/register", response_model=Token)
def register(req: LoginRegisterModel, session: SessionDep) -> Token: def register(req: LoginRegisterModel, session: SessionDep) -> Token:
if not settings.REGISTER_ENABLE: if not settings.REGISTER_ENABLE:

View File

@ -1,8 +1,8 @@
from pathlib import Path from pathlib import Path
from typing import Annotated from typing import Annotated
from fastapi import (APIRouter, BackgroundTasks, Depends, File, HTTPException, from fastapi import (APIRouter, BackgroundTasks, Body, Depends, File,
UploadFile) HTTPException, UploadFile)
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from sqlmodel import select from sqlmodel import select
@ -10,6 +10,7 @@ from ..config import settings
from ..deps import SessionDep, get_current_username from ..deps import SessionDep, get_current_username
from ..models.models import (Backup, BackupRead, BackupStatus, User, UserRead, from ..models.models import (Backup, BackupRead, BackupStatus, User, UserRead,
UserUpdate) UserUpdate)
from ..security import generate_totp_secret, verify_totp_code
from ..utils.utils import check_update from ..utils.utils import check_update
from ..utils.zip import (process_backup_export, process_backup_import, from ..utils.zip import (process_backup_export, process_backup_import,
process_legacy_import) process_legacy_import)
@ -46,6 +47,71 @@ def put_user_settings(
return UserRead.serialize(db_user) return UserRead.serialize(db_user)
@router.post("/totp")
async def enable_totp(session: SessionDep, current_user: Annotated[str, Depends(get_current_username)]):
db_user = session.get(User, current_user)
if not db_user:
raise HTTPException(status_code=404, detail="The resource does not exist")
if db_user.totp_enabled:
raise HTTPException(status_code=400, detail="Bad request")
totp_secret = generate_totp_secret()
db_user.totp_secret = totp_secret
session.add(db_user)
session.commit()
return {"secret": totp_secret}
@router.post("/totp/verify")
async def verify_totp(
session: SessionDep,
current_user: Annotated[str, Depends(get_current_username)],
code: str = Body(..., embed=True),
):
db_user = session.get(User, current_user)
if not db_user:
raise HTTPException(status_code=404, detail="The resource does not exist")
if not db_user.totp_secret or db_user.totp_enabled:
raise HTTPException(status_code=400, detail="Bad request")
success = verify_totp_code(db_user.totp_secret, code)
if not success:
db_user.totp_secret = None
session.add(db_user)
session.commit()
raise HTTPException(status_code=403, detail="Invalid code")
db_user.totp_enabled = True
session.add(db_user)
session.commit()
return {}
@router.delete("/totp/{code}")
async def delete_totp(
session: SessionDep, code: str, current_user: Annotated[str, Depends(get_current_username)]
):
db_user = session.get(User, current_user)
if not db_user or not db_user.totp_enabled or not db_user.totp_secret:
raise HTTPException(status_code=400, detail="Bad request")
success = verify_totp_code(db_user.totp_secret, code)
if not success:
raise HTTPException(status_code=403, detail="Invalid code")
db_user.totp_secret = None
db_user.totp_enabled = False
session.add(db_user)
session.commit()
return {}
@router.get("/checkversion") @router.get("/checkversion")
async def check_version(session: SessionDep, current_user: Annotated[str, Depends(get_current_username)]): async def check_version(session: SessionDep, current_user: Annotated[str, Depends(get_current_username)]):
return await check_update() return await check_update()

View File

@ -1,6 +1,7 @@
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
import jwt import jwt
import pyotp
from argon2 import PasswordHasher from argon2 import PasswordHasher
from argon2 import exceptions as argon_exceptions from argon2 import exceptions as argon_exceptions
from authlib.integrations.httpx_client import OAuth2Client from authlib.integrations.httpx_client import OAuth2Client
@ -14,6 +15,15 @@ ph = PasswordHasher()
OIDC_CONFIG = {} OIDC_CONFIG = {}
def generate_totp_secret() -> str:
return pyotp.random_base32()
def verify_totp_code(secret: str, code: str) -> bool:
totp = pyotp.TOTP(secret)
return totp.verify(code)
def hash_password(password: str) -> str: def hash_password(password: str) -> str:
return ph.hash(password) return ph.hash(password)

View File

@ -0,0 +1,18 @@
@if (token) {
<div class="p-4 text-center">
<p class="whitespace-pre">{{ message }}</p>
<div class="mt-4 flex gap-4 justify-center items-center">
<pre><p class="py-2 text-base font-light">{{ token }}</p></pre>
<p-button label="Copy" severity="secondary" icon="pi pi-copy" [cdkCopyToClipboard]="token" />
</div>
</div>
}
<div class="mt-4 flex flex-col items-center gap-2">
<p class="text-center font-light text-gray-500">
Enter your TOTP code to continue
</p>
<p-inputotp [(ngModel)]="otp" [integerOnly]="true" size="large" [length]="6" />
<p-button text label="Verify" [disabled]="otp.length < 6" (click)="close()" />
</div>

View File

@ -0,0 +1,33 @@
import { Component } from '@angular/core';
import { ButtonModule } from 'primeng/button';
import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog';
import { ClipboardModule } from '@angular/cdk/clipboard';
import { InputOtpModule } from 'primeng/inputotp';
import { FormsModule } from '@angular/forms';
@Component({
selector: 'app-totp-verify-modal',
imports: [ButtonModule, ClipboardModule, InputOtpModule, FormsModule],
standalone: true,
templateUrl: './totp-verify-modal.component.html',
styleUrl: './totp-verify-modal.component.scss',
})
export class TotpVerifyModalComponent {
token: string = '';
message: string = '';
otp: string = '';
constructor(
private ref: DynamicDialogRef,
private config: DynamicDialogConfig,
) {
if (this.config.data) {
this.token = this.config.data.token;
this.message = this.config.data.message;
}
}
close() {
this.ref.close(this.otp);
}
}

View File

@ -309,4 +309,16 @@ export class ApiService {
responseType: 'blob', responseType: 'blob',
}); });
} }
enableTOTP(): Observable<{ secret: string }> {
return this.httpClient.post<{ secret: string }>(this.apiBaseUrl + '/settings/totp', {});
}
disableTOTP(code: string): Observable<{}> {
return this.httpClient.delete<{}>(this.apiBaseUrl + `/settings/totp/${code}`);
}
verifyTOTP(code: string): Observable<any> {
return this.httpClient.post<any>(this.apiBaseUrl + '/settings/totp/verify', { code });
}
} }

View File

@ -11,6 +11,11 @@ export interface Token {
access_token: string; access_token: string;
} }
export interface TOTPRequired {
pending_code: string;
username: string;
}
export interface AuthParams { export interface AuthParams {
register_enabled: boolean; register_enabled: boolean;
oidc?: string; oidc?: string;
@ -102,11 +107,28 @@ export class AuthService {
return this.refreshInProgressLock$.asObservable(); return this.refreshInProgressLock$.asObservable();
} }
login(authForm: { username: string; password: string }): Observable<Token> { login(authForm: { username: string; password: string }): Observable<Token | TOTPRequired> {
return this.httpClient.post<Token>(this.apiBaseUrl + '/auth/login', authForm).pipe( return this.httpClient.post<Token | TOTPRequired>(this.apiBaseUrl + '/auth/login', authForm).pipe(
tap((tokens: Token) => { tap((data: any) => {
if (data.access_token && data.refresh_token) {
this.loggedUser = authForm.username; this.loggedUser = authForm.username;
this.storeTokens(tokens); this.storeTokens(data);
}
}),
);
}
verify_totp(username: string, pending_code: string, code: string): Observable<Token> {
return this.httpClient
.post<Token>(this.apiBaseUrl + '/auth/login_totp', {
username: username,
pending_code: pending_code,
code: code,
})
.pipe(
tap((Token) => {
this.loggedUser = username;
this.storeTokens(Token);
}), }),
); );
} }

View File

@ -5,6 +5,7 @@ import { ApiService } from './api.service';
import { map } from 'rxjs'; import { map } from 'rxjs';
type ToastSeverity = 'info' | 'warn' | 'error' | 'success'; type ToastSeverity = 'info' | 'warn' | 'error' | 'success';
const JWT_USER = 'TRIP_USER';
@Injectable({ @Injectable({
providedIn: 'root', providedIn: 'root',