package api import ( "errors" "log" "net/http" "strings" "time" "github.com/vasyakrg/dns-autoresolver/internal/auth" "github.com/vasyakrg/dns-autoresolver/internal/store" ) const sessionCookieName = "session" // dummyPasswordHash is a valid-format argon2 hash with no real matching // password. handleLogin runs VerifyPassword against it whenever the email // lookup fails, so a login attempt for an unregistered email takes the same // wall-clock time as one for a registered email with a wrong password — // otherwise the timing difference would let an attacker enumerate which // emails are registered. var dummyPasswordHash string func init() { h, err := auth.HashPassword("dns-autoresolver-timing-guard-dummy") if err != nil { panic("api: failed to initialize dummy password hash: " + err.Error()) } dummyPasswordHash = h } // normalizeEmail trims surrounding whitespace and lowercases the email so // storage and lookup are always consistent regardless of how the client // cased or padded the input. func normalizeEmail(email string) string { return strings.ToLower(strings.TrimSpace(email)) } func setSessionCookie(w http.ResponseWriter, token string, exp time.Time) { http.SetCookie(w, &http.Cookie{ Name: sessionCookieName, Value: token, Path: "/", HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode, Expires: exp, }) } func clearSessionCookie(w http.ResponseWriter) { http.SetCookie(w, &http.Cookie{ Name: sessionCookieName, Value: "", Path: "/", HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode, MaxAge: -1, }) } func (a *API) handleRegister(w http.ResponseWriter, r *http.Request) { var req registerRequest if !decodeBody(w, r, &req) { return } email := normalizeEmail(req.Email) if email == "" || req.Password == "" { writeErr(w, http.StatusBadRequest, "email and password are required") return } // Server-side minimum length is the source of truth: the client-side // zod min(8) check is UX only and can be bypassed with a direct POST. if len(req.Password) < 8 { writeErr(w, http.StatusBadRequest, "password must be at least 8 characters") return } hash, err := auth.HashPassword(req.Password) if err != nil { log.Printf("api: hash password failed: %v", err) writeErr(w, http.StatusInternalServerError, "internal error") return } u, p, err := a.Auth.RegisterUser(r.Context(), email, hash) if err != nil { if errors.Is(err, store.ErrEmailTaken) { writeErr(w, http.StatusConflict, "email already registered") return } log.Printf("api: register user failed: %v", err) writeErr(w, http.StatusInternalServerError, "internal error") return } token, exp, err := a.Sessions.Create(r.Context(), u.ID) if err != nil { log.Printf("api: create session failed: %v", err) writeErr(w, http.StatusInternalServerError, "internal error") return } setSessionCookie(w, token, exp) writeJSON(w, http.StatusOK, toAuthResponse(u, p)) } // invalidCredentials is deliberately identical for "no such user" and "wrong // password" — disclosing which one occurred would let an attacker enumerate // registered emails. func invalidCredentials(w http.ResponseWriter) { writeErr(w, http.StatusUnauthorized, "invalid credentials") } func (a *API) handleLogin(w http.ResponseWriter, r *http.Request) { var req loginRequest if !decodeBody(w, r, &req) { return } email := normalizeEmail(req.Email) u, err := a.Auth.GetUserByEmail(r.Context(), email) if err != nil { // No such user: still spend the argon2 verification cost against a // fixed dummy hash (see dummyPasswordHash) so this path isn't // distinguishable by timing from a wrong-password rejection below. _, _ = auth.VerifyPassword(dummyPasswordHash, req.Password) invalidCredentials(w) return } ok, err := auth.VerifyPassword(u.PasswordHash, req.Password) if err != nil || !ok { invalidCredentials(w) return } p, err := a.Auth.GetUserProject(r.Context(), u.ID) if err != nil { log.Printf("api: get user project failed: %v", err) writeErr(w, http.StatusInternalServerError, "internal error") return } token, exp, err := a.Sessions.Create(r.Context(), u.ID) if err != nil { log.Printf("api: create session failed: %v", err) writeErr(w, http.StatusInternalServerError, "internal error") return } setSessionCookie(w, token, exp) writeJSON(w, http.StatusOK, toAuthResponse(u, p)) } func (a *API) handleLogout(w http.ResponseWriter, r *http.Request) { if c, err := r.Cookie(sessionCookieName); err == nil && c.Value != "" { if err := a.Sessions.Destroy(r.Context(), c.Value); err != nil { log.Printf("api: destroy session failed: %v", err) } } clearSessionCookie(w) writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) } // handleMe returns the authenticated caller's identity + default project. // The user ID comes from the request context, set by RequireAuth after // validating the session cookie. func (a *API) handleMe(w http.ResponseWriter, r *http.Request) { userID, ok := userIDFrom(r.Context()) if !ok { writeErr(w, http.StatusUnauthorized, "authentication required") return } u, err := a.Auth.GetUserByID(r.Context(), userID) if err != nil { log.Printf("api: get user by id failed: %v", err) writeErr(w, http.StatusInternalServerError, "internal error") return } p, err := a.Auth.GetUserProject(r.Context(), userID) if err != nil { log.Printf("api: get user project failed: %v", err) writeErr(w, http.StatusInternalServerError, "internal error") return } writeJSON(w, http.StatusOK, toAuthResponse(u, p)) }