Files
enkly-ai/services/gateway/internal/handlers/auth.go
enkly 79ce8dc4c0
Some checks failed
Go CI Pipeline / build_and_test (push) Failing after 22m0s
Init Project
2026-10-30 18:06:55 +01:00

211 lines
7.8 KiB
Go

// Package handlers (auth.go) replaces internal/handlers/auth.go. Every
// handler here does the same HTTP-shape work as before (parse the
// form, render the auth.html templates) but delegates every password
// check, rate limit, token mint, and email dispatch to one IAM RPC —
// the gateway itself no longer touches a password hash, a session
// table, or SMTP.
package handlers
import (
"errors"
"net/http"
iamv1 "enkly/gen/iam/v1"
"enkly/pkg/i18n"
)
type authPageData struct {
CSRFToken string
Email string
Token string // reset_password only
Error string
Info string
Lang string
}
// authData is a small constructor so every call site below gets Lang
// filled in automatically instead of relying on each of the dozen
// authPageData{...} literals to remember it.
func (h *Handlers) authData(r *http.Request, w http.ResponseWriter) authPageData {
return authPageData{CSRFToken: h.csrfToken(w, r), Lang: string(localeFrom(r))}
}
func (h *Handlers) LoginPage(w http.ResponseWriter, r *http.Request) {
h.render(w, http.StatusOK, "login", h.authData(r, w))
}
func (h *Handlers) Login(w http.ResponseWriter, r *http.Request) {
lang := localeFrom(r)
email, _ := normalizeEmail(r.PostFormValue("email"))
resp, err := h.IAM.Login(r.Context(), &iamv1.LoginRequest{
Email: email, Password: r.PostFormValue("password"), ClientIp: clientIP(r),
})
if err != nil {
h.Logger.Error("login", "err", err)
d := h.authData(r, w)
d.Email, d.Error = email, i18n.T(lang, "errors.generic")
h.render(w, http.StatusInternalServerError, "login", d)
return
}
switch {
case resp.GetOk():
h.startSessionCookie(w, resp.GetSessionToken(), resp.GetSessionExpiresAt().AsTime())
http.Redirect(w, r, "/", http.StatusSeeOther)
case resp.GetNeedsVerification():
http.Redirect(w, r, "/verify-email?email="+email, http.StatusSeeOther)
default:
d := h.authData(r, w)
d.Email, d.Error = email, i18n.T(lang, "errors.incorrect_credentials")
h.render(w, http.StatusUnauthorized, "login", d)
}
}
func (h *Handlers) Logout(w http.ResponseWriter, r *http.Request) {
if raw := sessionToken(r); raw != "" {
_, _ = h.IAM.Logout(r.Context(), &iamv1.LogoutRequest{SessionToken: raw})
}
h.setCookie(w, sessionCookieName, "", -1)
http.Redirect(w, r, "/login", http.StatusSeeOther)
}
func (h *Handlers) SignupPage(w http.ResponseWriter, r *http.Request) {
h.render(w, http.StatusOK, "signup", h.authData(r, w))
}
func (h *Handlers) Signup(w http.ResponseWriter, r *http.Request) {
lang := localeFrom(r)
email, err := normalizeEmail(r.PostFormValue("email"))
if err != nil {
d := h.authData(r, w)
d.Error = i18n.T(lang, "errors.invalid_email")
h.render(w, http.StatusBadRequest, "signup", d)
return
}
if err := validatePassword(r.PostFormValue("password"), r.PostFormValue("password_confirm")); err != nil {
d := h.authData(r, w)
d.Email, d.Error = email, translatePasswordError(lang, err)
h.render(w, http.StatusBadRequest, "signup", d)
return
}
resp, err := h.IAM.SignUp(r.Context(), &iamv1.SignUpRequest{
Email: email, Password: r.PostFormValue("password"), PasswordConfirm: r.PostFormValue("password_confirm"), ClientIp: clientIP(r),
})
if err != nil {
h.Logger.Error("signup", "err", err)
d := h.authData(r, w)
d.Email, d.Error = email, i18n.T(lang, "errors.generic")
h.render(w, http.StatusInternalServerError, "signup", d)
return
}
info := i18n.T(lang, "info.check_inbox")
if !resp.GetMailConfigured() {
info = i18n.T(lang, "info.mail_not_configured")
}
http.Redirect(w, r, "/verify-email?email="+email+"&info="+info, http.StatusSeeOther)
}
func (h *Handlers) VerifyEmailPage(w http.ResponseWriter, r *http.Request) {
d := h.authData(r, w)
d.Email, d.Info = r.URL.Query().Get("email"), r.URL.Query().Get("info")
h.render(w, http.StatusOK, "verify_email", d)
}
func (h *Handlers) VerifyEmail(w http.ResponseWriter, r *http.Request) {
lang := localeFrom(r)
email, _ := normalizeEmail(r.PostFormValue("email"))
code := r.PostFormValue("code")
if !isSixDigitCode(code) {
d := h.authData(r, w)
d.Email, d.Error = email, i18n.T(lang, "errors.invalid_code")
h.render(w, http.StatusBadRequest, "verify_email", d)
return
}
resp, err := h.IAM.VerifyEmail(r.Context(), &iamv1.VerifyEmailRequest{Email: email, Code: code, ClientIp: clientIP(r)})
if err != nil {
h.Logger.Error("verify email", "err", err)
d := h.authData(r, w)
d.Email, d.Error = email, i18n.T(lang, "errors.generic")
h.render(w, http.StatusInternalServerError, "verify_email", d)
return
}
if !resp.GetVerified() {
d := h.authData(r, w)
d.Email, d.Error = email, i18n.T(lang, "errors.code_incorrect")
h.render(w, http.StatusBadRequest, "verify_email", d)
return
}
http.Redirect(w, r, "/login?info="+i18n.T(lang, "info.email_verified"), http.StatusSeeOther)
}
func (h *Handlers) ResendVerification(w http.ResponseWriter, r *http.Request) {
email, _ := normalizeEmail(r.PostFormValue("email"))
_, _ = h.IAM.ResendVerification(r.Context(), &iamv1.ResendVerificationRequest{Email: email, ClientIp: clientIP(r)})
http.Redirect(w, r, "/verify-email?email="+email+"&info="+i18n.T(localeFrom(r), "info.resend_sent"), http.StatusSeeOther)
}
func (h *Handlers) ForgotPasswordPage(w http.ResponseWriter, r *http.Request) {
h.render(w, http.StatusOK, "forgot_password", h.authData(r, w))
}
func (h *Handlers) ForgotPassword(w http.ResponseWriter, r *http.Request) {
email, _ := normalizeEmail(r.PostFormValue("email"))
_, _ = h.IAM.ForgotPassword(r.Context(), &iamv1.ForgotPasswordRequest{Email: email, ClientIp: clientIP(r)})
d := h.authData(r, w)
d.Info = i18n.T(localeFrom(r), "info.forgot_sent")
h.render(w, http.StatusOK, "forgot_password", d)
}
func (h *Handlers) ResetPasswordPage(w http.ResponseWriter, r *http.Request) {
token := r.URL.Query().Get("token")
resp, err := h.IAM.CheckPasswordResetToken(r.Context(), &iamv1.CheckPasswordResetTokenRequest{Token: token})
if err != nil || !resp.GetValid() {
d := h.authData(r, w)
d.Error = i18n.T(localeFrom(r), "errors.reset_link_invalid")
h.render(w, http.StatusBadRequest, "reset_password", d)
return
}
d := h.authData(r, w)
d.Token = token
h.render(w, http.StatusOK, "reset_password", d)
}
func (h *Handlers) ResetPassword(w http.ResponseWriter, r *http.Request) {
lang := localeFrom(r)
token := r.PostFormValue("token")
if err := validatePassword(r.PostFormValue("password"), r.PostFormValue("password_confirm")); err != nil {
d := h.authData(r, w)
d.Token, d.Error = token, translatePasswordError(lang, err)
h.render(w, http.StatusBadRequest, "reset_password", d)
return
}
resp, err := h.IAM.ResetPassword(r.Context(), &iamv1.ResetPasswordRequest{
Token: token, Password: r.PostFormValue("password"), PasswordConfirm: r.PostFormValue("password_confirm"),
})
if err != nil || !resp.GetOk() {
d := h.authData(r, w)
d.Token, d.Error = token, i18n.T(lang, "errors.reset_link_invalid")
h.render(w, http.StatusBadRequest, "reset_password", d)
return
}
http.Redirect(w, r, "/login?info="+i18n.T(lang, "info.password_reset_done"), http.StatusSeeOther)
}
// translatePasswordError maps one of validatePassword's sentinel
// errors (see core.go) onto the matching catalog key, via errors.Is
// rather than matching the error's English text — so this still works
// correctly no matter what validatePassword's messages say, or if
// minPasswordLen/maxPasswordLen ever change. Any error that isn't one
// of the three known sentinels falls back to errors.generic.
func translatePasswordError(lang i18n.Locale, err error) string {
switch {
case errors.Is(err, errPasswordTooShort):
return i18n.T(lang, "errors.password_min", minPasswordLen)
case errors.Is(err, errPasswordTooLong):
return i18n.T(lang, "errors.password_max", maxPasswordLen)
case errors.Is(err, errPasswordMismatch):
return i18n.T(lang, "errors.password_mismatch")
default:
return i18n.T(lang, "errors.generic")
}
}