VerifyTfa handles POST /auth/verify-tfa.
(w http.ResponseWriter, r *http.Request)
| 326 | |
| 327 | // VerifyTfa handles POST /auth/verify-tfa. |
| 328 | func (h *AuthHandler) VerifyTfa(w http.ResponseWriter, r *http.Request) { |
| 329 | var req VerifyTfaRequest |
| 330 | if err := decodeJSON(r, &req); err != nil { |
| 331 | Error(w, http.StatusBadRequest, "Invalid request body") |
| 332 | return |
| 333 | } |
| 334 | req.Token = strings.ToUpper(strings.TrimSpace(req.Token)) |
| 335 | if req.Username == "" || len(req.Token) != 6 { |
| 336 | Error(w, http.StatusBadRequest, "Username and 6-character token required") |
| 337 | return |
| 338 | } |
| 339 | if !util.TokenRegex.MatchString(req.Token) { |
| 340 | Error(w, http.StatusBadRequest, "Token must be 6 alphanumeric characters") |
| 341 | return |
| 342 | } |
| 343 | |
| 344 | user, err := h.users.GetByUsernameOrEmail(r.Context(), req.Username) |
| 345 | if err != nil || user == nil || !user.IsActive || !user.TfaEnabled || user.TfaSecret == nil { |
| 346 | Error(w, http.StatusUnauthorized, "Invalid credentials or TFA not enabled") |
| 347 | return |
| 348 | } |
| 349 | |
| 350 | if h.tfaLockout != nil { |
| 351 | locked, _ := h.tfaLockout.IsTFALocked(r.Context(), user.ID) |
| 352 | if locked { |
| 353 | Error(w, http.StatusTooManyRequests, "Too many failed TFA attempts. Please try again later.") |
| 354 | return |
| 355 | } |
| 356 | } |
| 357 | |
| 358 | secret := strings.TrimSpace(*user.TfaSecret) |
| 359 | verified := util.VerifyTOTP(secret, req.Token, util.TOTPWindow) |
| 360 | |
| 361 | if !verified { |
| 362 | hashed := util.ParseBackupCodesJSON(user.TfaBackupCodes) |
| 363 | if len(hashed) > 0 { |
| 364 | valid, idx := util.VerifyBackupCode(req.Token, hashed) |
| 365 | if valid { |
| 366 | verified = true |
| 367 | hashed = append(hashed[:idx], hashed[idx+1:]...) |
| 368 | updated := util.EncodeBackupCodesJSON(hashed) |
| 369 | _ = h.users.UpdateTfaBackupCodes(r.Context(), user.ID, &updated) |
| 370 | } |
| 371 | } |
| 372 | } |
| 373 | |
| 374 | if !verified { |
| 375 | if h.tfaLockout != nil { |
| 376 | attempts, locked := h.tfaLockout.RecordFailedAttempt(r.Context(), user.ID) |
| 377 | if locked { |
| 378 | Error(w, http.StatusTooManyRequests, "Too many failed TFA attempts. Please try again later.") |
| 379 | return |
| 380 | } |
| 381 | remaining := h.getMaxTfaAttempts() - attempts |
| 382 | if remaining < 0 { |
| 383 | remaining = 0 |
| 384 | } |
| 385 | JSON(w, http.StatusUnauthorized, map[string]interface{}{ |
nothing calls this directly
no test coverage detected