183 lines
5.5 KiB
Go
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))
|
|
}
|