Catalyst / admin/Strike 14.8 GB / 57.8 GB 40.0 GB free
Help Sign in

admin / Strike

public

Web-Based UK Cyber Compliance Tool with Reporting

Code Issues Pull requests Pipelines Packages Security Insights Wiki Settings
Strike / strikexi-v2 / backend / app / routers / auth.py 7427 B · main
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
from datetime import datetime, timezone

from fastapi import APIRouter, Depends, HTTPException, status, Request
from fastapi.security import OAuth2PasswordRequestForm
from jose import jwt, JWTError
from sqlalchemy.orm import Session

from .. import models, schemas
from ..config import settings
from ..database import get_db
from ..security import (
    verify_password, create_access_token, client_ip, write_audit,
    get_current_user, verify_totp, hash_password, password_problem,
)

router = APIRouter(prefix="/api/auth", tags=["auth"])


def _token_response(user: models.User) -> schemas.Token:
    token = create_access_token(user.username, scope="access")
    return schemas.Token(
        access_token=token,
        username=user.username,
        role=user.role,
        must_change_password=user.must_change_password,
        mfa_required=user.mfa_required,
        mfa_enabled=user.mfa_enabled,
        first_login_pending=user.first_login_pending,
    )


@router.post("/signup", status_code=201)
def signup(payload: schemas.SignupCreate, request: Request,
           db: Session = Depends(get_db)):
    """
    Public self-service signup. Creates a new account with the ``user`` role.

    The new user can only ever see the assessments they create. MFA is enforced
    on their first sign-in (``mfa_required`` + ``first_login_pending``).
    """
    ip = client_ip(request)
    surname = (payload.surname or "").strip()
    first_name = (payload.first_name or "").strip()
    company = (payload.company_name or "").strip()
    email = (payload.email or "").strip()
    contact = (payload.contact_number or "").strip()
    username = (payload.username or "").strip()

    missing = [n for n, v in [
        ("Surname", surname), ("First Name", first_name),
        ("Company Name", company), ("Email Address", email),
        ("Contact Number", contact), ("Username", username),
    ] if not v]
    if missing:
        raise HTTPException(status_code=400,
                            detail=f"Required field(s) missing: {', '.join(missing)}.")
    if "@" not in email or "." not in email.split("@")[-1]:
        raise HTTPException(status_code=400, detail="Please enter a valid email address.")

    prob = password_problem(payload.password)
    if prob:
        raise HTTPException(status_code=400, detail=prob)

    if db.query(models.User).filter(models.User.username == username).first():
        raise HTTPException(status_code=409, detail=f"Username '{username}' is already taken.")

    user = models.User(
        username=username,
        password_hash=hash_password(payload.password),
        full_name=f"{first_name} {surname}".strip(),
        role="user",
        is_active=True,
        must_change_password=False,
        first_name=first_name,
        surname=surname,
        company_name=company,
        email=email,
        contact_number=contact,
        signup_origin="self_service",
        # Enforce MFA enrolment on first login.
        mfa_required=True,
        mfa_enabled=False,
        first_login_pending=True,
    )
    db.add(user)
    db.commit()
    write_audit(db, username, "SIGNUP",
                f"Self-service signup company='{company}' email='{email}'", ip)
    return {"detail": "Account created. Please sign in; you'll be asked to set up "
                      "multi-factor authentication on first login."}


@router.post("/login")
def login(request: Request, form: OAuth2PasswordRequestForm = Depends(),
          db: Session = Depends(get_db)):
    """
    Step 1 of login: validate username + password.

    * If the user has MFA enabled, returns a short-lived ``mfa_token`` and
      ``mfa_required: true`` instead of an access token; the client must then
      call ``/login/mfa`` with a TOTP code.
    * Otherwise returns a normal access token.
    """
    ip = client_ip(request)
    user = db.query(models.User).filter(models.User.username == form.username).first()
    if not user or not verify_password(form.password, user.password_hash):
        write_audit(db, form.username, "LOGIN_FAILED", "Invalid credentials", ip)
        raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED,
                            detail="Invalid username or password")
    if not user.is_active:
        write_audit(db, form.username, "LOGIN_FAILED", "Account disabled", ip)
        raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Account disabled")

    if user.mfa_enabled and user.mfa_secret:
        # Password OK but a second factor is required.
        mfa_token = create_access_token(user.username, scope="mfa", minutes=5)
        write_audit(db, user.username, "LOGIN_MFA_CHALLENGE",
                    "Password OK; awaiting TOTP", ip)
        return schemas.MfaChallenge(mfa_token=mfa_token, username=user.username)

    user.last_login = datetime.now(timezone.utc)
    # A self-service account on its very first login has MFA required but not yet
    # enabled; the client routes them straight to MFA enrolment. We leave
    # first_login_pending set until MFA is actually confirmed (see users router).
    db.commit()
    if user.mfa_required and not user.mfa_enabled:
        write_audit(db, user.username, "LOGIN_SUCCESS",
                    "Password only; MFA enrolment required", ip)
    else:
        write_audit(db, user.username, "LOGIN_SUCCESS", "Password only", ip)
    return _token_response(user)


@router.post("/login/mfa", response_model=schemas.Token)
def login_mfa(request: Request, payload: dict, db: Session = Depends(get_db)):
    """Step 2 of login: verify the TOTP code against the mfa_token subject."""
    ip = client_ip(request)
    mfa_token = payload.get("mfa_token", "")
    code = payload.get("code", "")
    cred_exc = HTTPException(status_code=status.HTTP_401_UNAUTHORIZED,
                             detail="MFA verification failed")
    try:
        data = jwt.decode(mfa_token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
        if data.get("scope") != "mfa":
            raise cred_exc
        username = data.get("sub")
    except JWTError:
        raise cred_exc

    user = db.query(models.User).filter(models.User.username == username).first()
    if not user or not user.is_active or not user.mfa_enabled:
        raise cred_exc
    if not verify_totp(user.mfa_secret, code):
        write_audit(db, username, "LOGIN_MFA_FAILED", "Bad TOTP code", ip)
        raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED,
                            detail="Incorrect authentication code")

    user.last_login = datetime.now(timezone.utc)
    if user.first_login_pending:
        user.first_login_pending = False
    db.commit()
    write_audit(db, user.username, "LOGIN_SUCCESS", "Password + MFA", ip)
    return _token_response(user)


@router.post("/logout")
def logout(request: Request, db: Session = Depends(get_db),
           user: models.User = Depends(get_current_user)):
    write_audit(db, user.username, "LOGOUT", "", client_ip(request))
    return {"detail": "Logged out"}


@router.get("/me")
def me(user: models.User = Depends(get_current_user)):
    return {
        "username": user.username,
        "role": user.role,
        "full_name": user.full_name,
        "must_change_password": user.must_change_password,
        "mfa_enabled": user.mfa_enabled,
        "mfa_required": user.mfa_required,
        "first_login_pending": user.first_login_pending,
    }