✨ TOTP
This commit is contained in:
parent
4c8c505e5e
commit
c0daaf5820
@ -91,6 +91,11 @@ class Token(BaseModel):
|
||||
refresh_token: str
|
||||
|
||||
|
||||
class PendingTOTP(BaseModel):
|
||||
pending_code: str
|
||||
username: str
|
||||
|
||||
|
||||
class ImageBase(SQLModel):
|
||||
filename: str
|
||||
|
||||
|
||||
@ -7,3 +7,4 @@ pydantic_settings~=2.9
|
||||
Pillow~=11.2
|
||||
authlib~=1.6
|
||||
alembic~=1.16
|
||||
pyotp~=2.9
|
||||
@ -5,12 +5,16 @@ from fastapi.responses import JSONResponse
|
||||
from ..config import settings
|
||||
from ..db.core import init_user_data
|
||||
from ..deps import SessionDep
|
||||
from ..models.models import AuthParams, LoginRegisterModel, Token, User
|
||||
from ..security import (create_access_token, create_tokens, get_oidc_client,
|
||||
get_oidc_config, hash_password, verify_password)
|
||||
from ..models.models import (AuthParams, LoginRegisterModel, PendingTOTP,
|
||||
Token, User)
|
||||
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
|
||||
|
||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||
pending_totp_usernames = {}
|
||||
|
||||
|
||||
@router.get("/params", response_model=AuthParams)
|
||||
@ -104,8 +108,8 @@ async def oidc_login(
|
||||
return create_tokens(data={"sub": username})
|
||||
|
||||
|
||||
@router.post("/login", response_model=Token)
|
||||
def login(req: LoginRegisterModel, session: SessionDep) -> Token:
|
||||
@router.post("/login", response_model=Token | PendingTOTP)
|
||||
def login(req: LoginRegisterModel, session: SessionDep) -> Token | PendingTOTP:
|
||||
if settings.OIDC_CLIENT_ID or settings.OIDC_CLIENT_SECRET:
|
||||
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):
|
||||
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})
|
||||
|
||||
|
||||
@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)
|
||||
def register(req: LoginRegisterModel, session: SessionDep) -> Token:
|
||||
if not settings.REGISTER_ENABLE:
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
from pathlib import Path
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import (APIRouter, BackgroundTasks, Depends, File, HTTPException,
|
||||
UploadFile)
|
||||
from fastapi import (APIRouter, BackgroundTasks, Body, Depends, File,
|
||||
HTTPException, UploadFile)
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlmodel import select
|
||||
|
||||
@ -10,6 +10,7 @@ from ..config import settings
|
||||
from ..deps import SessionDep, get_current_username
|
||||
from ..models.models import (Backup, BackupRead, BackupStatus, User, UserRead,
|
||||
UserUpdate)
|
||||
from ..security import generate_totp_secret, verify_totp_code
|
||||
from ..utils.utils import check_update
|
||||
from ..utils.zip import (process_backup_export, process_backup_import,
|
||||
process_legacy_import)
|
||||
@ -46,6 +47,71 @@ def put_user_settings(
|
||||
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")
|
||||
async def check_version(session: SessionDep, current_user: Annotated[str, Depends(get_current_username)]):
|
||||
return await check_update()
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import jwt
|
||||
import pyotp
|
||||
from argon2 import PasswordHasher
|
||||
from argon2 import exceptions as argon_exceptions
|
||||
from authlib.integrations.httpx_client import OAuth2Client
|
||||
@ -14,6 +15,15 @@ ph = PasswordHasher()
|
||||
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:
|
||||
return ph.hash(password)
|
||||
|
||||
|
||||
@ -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>
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -309,4 +309,16 @@ export class ApiService {
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
@ -11,6 +11,11 @@ export interface Token {
|
||||
access_token: string;
|
||||
}
|
||||
|
||||
export interface TOTPRequired {
|
||||
pending_code: string;
|
||||
username: string;
|
||||
}
|
||||
|
||||
export interface AuthParams {
|
||||
register_enabled: boolean;
|
||||
oidc?: string;
|
||||
@ -102,15 +107,32 @@ export class AuthService {
|
||||
return this.refreshInProgressLock$.asObservable();
|
||||
}
|
||||
|
||||
login(authForm: { username: string; password: string }): Observable<Token> {
|
||||
return this.httpClient.post<Token>(this.apiBaseUrl + '/auth/login', authForm).pipe(
|
||||
tap((tokens: Token) => {
|
||||
this.loggedUser = authForm.username;
|
||||
this.storeTokens(tokens);
|
||||
login(authForm: { username: string; password: string }): Observable<Token | TOTPRequired> {
|
||||
return this.httpClient.post<Token | TOTPRequired>(this.apiBaseUrl + '/auth/login', authForm).pipe(
|
||||
tap((data: any) => {
|
||||
if (data.access_token && data.refresh_token) {
|
||||
this.loggedUser = authForm.username;
|
||||
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);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
register(authForm: { username: string; password: string }): Observable<Token> {
|
||||
return this.httpClient.post<Token>(this.apiBaseUrl + '/auth/register', authForm).pipe(
|
||||
tap((tokens: Token) => {
|
||||
|
||||
@ -5,6 +5,7 @@ import { ApiService } from './api.service';
|
||||
import { map } from 'rxjs';
|
||||
|
||||
type ToastSeverity = 'info' | 'warn' | 'error' | 'success';
|
||||
const JWT_USER = 'TRIP_USER';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user