211 lines
7.8 KiB
Go
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")
|
|
}
|
|
}
|