Files

183 lines
5.5 KiB
Go

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))
}