✨ TOTP
This commit is contained in:
parent
4c8c505e5e
commit
c0daaf5820
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
@ -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:
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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)
|
||||||
|
|
||||||
|
|||||||
@ -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',
|
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;
|
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,15 +107,32 @@ 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) => {
|
||||||
this.loggedUser = authForm.username;
|
if (data.access_token && data.refresh_token) {
|
||||||
this.storeTokens(tokens);
|
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> {
|
register(authForm: { username: string; password: string }): Observable<Token> {
|
||||||
return this.httpClient.post<Token>(this.apiBaseUrl + '/auth/register', authForm).pipe(
|
return this.httpClient.post<Token>(this.apiBaseUrl + '/auth/register', authForm).pipe(
|
||||||
tap((tokens: Token) => {
|
tap((tokens: Token) => {
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user