- Replace manual IP logging with the `withRequestLogAttrs` helper in authentication handlers. - Add user activity logging for API documentation and login page views. - Clean up log calls to use variadic expansion of request attributes.
337 lines
13 KiB
Go
337 lines
13 KiB
Go
package handlers
|
|
|
|
import (
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"warpbox.dev/backend/libs/services"
|
|
"warpbox.dev/backend/libs/web"
|
|
)
|
|
|
|
const userSessionCookieName = "warpbox_session"
|
|
|
|
type authPageData struct {
|
|
Mode string
|
|
Token string
|
|
Email string
|
|
IsReset bool
|
|
Error string
|
|
ReturnPath string
|
|
}
|
|
|
|
func (a *App) Register(w http.ResponseWriter, r *http.Request) {
|
|
available, err := a.authService.BootstrapAvailable()
|
|
if err != nil {
|
|
http.Error(w, "unable to check registration", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if !available {
|
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
|
return
|
|
}
|
|
a.renderAuth(w, r, http.StatusOK, authPageData{Mode: "register"})
|
|
}
|
|
|
|
func (a *App) RegisterPost(w http.ResponseWriter, r *http.Request) {
|
|
if !a.rateLimiter.Allow("register:"+uploadClientIP(r), 10, time.Minute, time.Now().UTC()) {
|
|
a.logger.Warn("registration rate limited", withRequestLogAttrs(r, "source", "auth", "severity", "warn", "code", 4291)...)
|
|
a.renderAuth(w, r, http.StatusTooManyRequests, authPageData{Mode: "register", Error: "Too many registration attempts."})
|
|
return
|
|
}
|
|
if err := r.ParseForm(); err != nil {
|
|
a.renderAuth(w, r, http.StatusBadRequest, authPageData{Mode: "register", Error: "Unable to read form."})
|
|
return
|
|
}
|
|
user, err := a.authService.CreateBootstrapUser(r.FormValue("username"), r.FormValue("email"), r.FormValue("password"))
|
|
if err != nil {
|
|
a.logger.Warn("bootstrap registration failed", withRequestLogAttrs(r, "source", "auth", "severity", "warn", "code", 4400, "email", r.FormValue("email"), "error", err.Error())...)
|
|
a.renderAuth(w, r, http.StatusBadRequest, authPageData{Mode: "register", Error: err.Error()})
|
|
return
|
|
}
|
|
a.logger.Info("first admin created", withRequestLogAttrs(r, "source", "auth", "severity", "user_activity", "code", 2401, "user_id", user.ID)...)
|
|
a.loginAndRedirect(w, r, user.Email, r.FormValue("password"), "/app")
|
|
}
|
|
|
|
func (a *App) Login(w http.ResponseWriter, r *http.Request) {
|
|
if _, ok := a.currentUser(r); ok {
|
|
http.Redirect(w, r, "/app", http.StatusSeeOther)
|
|
return
|
|
}
|
|
a.logger.Info("login page viewed", withRequestLogAttrs(r, "source", "page", "severity", "user_activity", "code", 2503, "actor", "anonymous")...)
|
|
a.renderAuth(w, r, http.StatusOK, authPageData{Mode: "login", ReturnPath: r.URL.Query().Get("next")})
|
|
}
|
|
|
|
func (a *App) LoginPost(w http.ResponseWriter, r *http.Request) {
|
|
if !a.rateLimiter.Allow("login:"+uploadClientIP(r), 10, time.Minute, time.Now().UTC()) {
|
|
a.logger.Warn("login rate limited", withRequestLogAttrs(r, "source", "auth", "severity", "warn", "code", 4292, "email", r.FormValue("email"))...)
|
|
a.renderAuth(w, r, http.StatusTooManyRequests, authPageData{Mode: "login", Error: "Too many login attempts."})
|
|
return
|
|
}
|
|
if err := r.ParseForm(); err != nil {
|
|
a.renderAuth(w, r, http.StatusBadRequest, authPageData{Mode: "login", Error: "Unable to read form."})
|
|
return
|
|
}
|
|
next := r.FormValue("next")
|
|
if next == "" {
|
|
next = "/app"
|
|
}
|
|
user, token, err := a.authService.Login(r.FormValue("email"), r.FormValue("password"))
|
|
if err != nil {
|
|
a.logger.Warn("login failed", withRequestLogAttrs(r, "source", "auth", "severity", "warn", "code", 4401, "email", r.FormValue("email"))...)
|
|
a.recordLoginAbuse(r, services.AbuseKindUserLogin, "user login failed")
|
|
a.renderAuth(w, r, http.StatusUnauthorized, authPageData{Mode: "login", Error: "Invalid email or password.", ReturnPath: next})
|
|
return
|
|
}
|
|
a.setUserSessionCookie(w, r, token)
|
|
a.logger.Info("user login", withRequestLogAttrs(r, "source", "auth", "severity", "user_activity", "code", 2402, "user_id", user.ID)...)
|
|
http.Redirect(w, r, safeReturnPath(next), http.StatusSeeOther)
|
|
}
|
|
|
|
func (a *App) Logout(w http.ResponseWriter, r *http.Request) {
|
|
if !a.validateCSRF(w, r) {
|
|
return
|
|
}
|
|
if user, ok := a.currentUser(r); ok {
|
|
a.logger.Info("user logout", withRequestLogAttrs(r, "source", "auth", "severity", "user_activity", "code", 2405, "user_id", user.ID)...)
|
|
}
|
|
if cookie, err := r.Cookie(userSessionCookieName); err == nil {
|
|
_ = a.authService.Logout(cookie.Value)
|
|
}
|
|
a.clearUserSessionCookie(w)
|
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
|
}
|
|
|
|
func (a *App) Invite(w http.ResponseWriter, r *http.Request) {
|
|
invite, err := a.authService.InviteByToken(r.PathValue("token"))
|
|
if err != nil || invite.UsedAt != nil || time.Now().UTC().After(invite.ExpiresAt) {
|
|
a.renderAuth(w, r, http.StatusNotFound, authPageData{Mode: "invite", Error: "This invite is invalid or expired."})
|
|
return
|
|
}
|
|
a.logger.Info("invite page viewed", withRequestLogAttrs(r, "source", "page", "severity", "user_activity", "code", 2504, "invite_email", invite.Email, "reset", invite.UserID != "")...)
|
|
a.renderAuth(w, r, http.StatusOK, authPageData{Mode: "invite", Token: r.PathValue("token"), Email: invite.Email, IsReset: invite.UserID != ""})
|
|
}
|
|
|
|
func (a *App) InvitePost(w http.ResponseWriter, r *http.Request) {
|
|
token := r.PathValue("token")
|
|
invite, err := a.authService.InviteByToken(token)
|
|
if err != nil {
|
|
a.logger.Warn("invite accept invalid", withRequestLogAttrs(r, "source", "auth", "severity", "warn", "code", 4404)...)
|
|
a.renderAuth(w, r, http.StatusNotFound, authPageData{Mode: "invite", Error: "This invite is invalid or expired."})
|
|
return
|
|
}
|
|
if err := r.ParseForm(); err != nil {
|
|
a.renderAuth(w, r, http.StatusBadRequest, authPageData{Mode: "invite", Token: token, Email: invite.Email, IsReset: invite.UserID != "", Error: "Unable to read form."})
|
|
return
|
|
}
|
|
user, err := a.authService.AcceptInvite(token, r.FormValue("username"), r.FormValue("password"))
|
|
if err != nil {
|
|
a.logger.Warn("invite accept failed", withRequestLogAttrs(r, "source", "auth", "severity", "warn", "code", 4405, "invite_email", invite.Email, "error", err.Error())...)
|
|
a.renderAuth(w, r, http.StatusBadRequest, authPageData{Mode: "invite", Token: token, Email: invite.Email, IsReset: invite.UserID != "", Error: err.Error()})
|
|
return
|
|
}
|
|
a.logger.Info("invite accepted", withRequestLogAttrs(r, "source", "auth", "severity", "user_activity", "code", 2403, "user_id", user.ID, "invite_email", invite.Email)...)
|
|
a.loginAndRedirect(w, r, user.Email, r.FormValue("password"), "/app")
|
|
}
|
|
|
|
type apiTokenView struct {
|
|
ID string
|
|
Name string
|
|
CreatedAt string
|
|
LastUsedAt string
|
|
}
|
|
|
|
type accountData struct {
|
|
ID string
|
|
Email string
|
|
Role string
|
|
Tokens []apiTokenView
|
|
NewToken string
|
|
Error string
|
|
}
|
|
|
|
func (a *App) AccountSettings(w http.ResponseWriter, r *http.Request) {
|
|
user, ok := a.requireUser(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
a.logger.Info("account settings viewed", withRequestLogAttrs(r, "source", "page", "severity", "user_activity", "code", 2505, "user_id", user.ID)...)
|
|
a.renderAccount(w, r, http.StatusOK, user, accountData{})
|
|
}
|
|
|
|
// CreateUserToken mints a new personal access token and renders the account
|
|
// page with the one-time plaintext shown. The secret is never recoverable after
|
|
// this response.
|
|
func (a *App) CreateUserToken(w http.ResponseWriter, r *http.Request) {
|
|
user, ok := a.requireUser(w, r)
|
|
if !ok || !a.validateCSRF(w, r) {
|
|
return
|
|
}
|
|
if err := r.ParseForm(); err != nil {
|
|
a.renderAccount(w, r, http.StatusBadRequest, user, accountData{Error: "Unable to read form."})
|
|
return
|
|
}
|
|
result, err := a.authService.CreateAPIToken(user.ID, r.FormValue("name"))
|
|
if err != nil {
|
|
a.logger.Warn("api token create failed", withRequestLogAttrs(r, "source", "user_activity", "severity", "warn", "code", 4420, "user_id", user.ID, "error", err.Error())...)
|
|
a.renderAccount(w, r, http.StatusBadRequest, user, accountData{Error: "Could not create token."})
|
|
return
|
|
}
|
|
a.logger.Info("api token created", withRequestLogAttrs(r, "source", "user_activity", "severity", "user_activity", "code", 2420, "user_id", user.ID, "token_id", result.Token.ID)...)
|
|
a.renderAccount(w, r, http.StatusOK, user, accountData{NewToken: result.Plaintext})
|
|
}
|
|
|
|
func (a *App) DeleteUserToken(w http.ResponseWriter, r *http.Request) {
|
|
user, ok := a.requireUser(w, r)
|
|
if !ok || !a.validateCSRF(w, r) {
|
|
return
|
|
}
|
|
if err := a.authService.DeleteAPIToken(user.ID, r.PathValue("tokenID")); err != nil {
|
|
a.logger.Warn("api token delete failed", withRequestLogAttrs(r, "source", "user_activity", "severity", "warn", "code", 4421, "user_id", user.ID, "error", err.Error())...)
|
|
} else {
|
|
a.logger.Info("api token deleted", withRequestLogAttrs(r, "source", "user_activity", "severity", "user_activity", "code", 2421, "user_id", user.ID, "token_id", r.PathValue("tokenID"))...)
|
|
}
|
|
http.Redirect(w, r, "/account/settings", http.StatusSeeOther)
|
|
}
|
|
|
|
func (a *App) renderAccount(w http.ResponseWriter, r *http.Request, status int, user services.User, data accountData) {
|
|
tokens, err := a.authService.ListAPITokens(user.ID)
|
|
if err != nil {
|
|
http.Error(w, "unable to load tokens", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
views := make([]apiTokenView, 0, len(tokens))
|
|
for _, token := range tokens {
|
|
lastUsed := "Never"
|
|
if token.LastUsedAt != nil {
|
|
lastUsed = token.LastUsedAt.Format("Jan 2, 2006 15:04")
|
|
}
|
|
views = append(views, apiTokenView{
|
|
ID: token.ID,
|
|
Name: token.Name,
|
|
CreatedAt: token.CreatedAt.Format("Jan 2, 2006"),
|
|
LastUsedAt: lastUsed,
|
|
})
|
|
}
|
|
data.ID = user.ID
|
|
data.Email = user.Email
|
|
data.Role = user.Role
|
|
data.Tokens = views
|
|
|
|
a.renderPage(w, r, status, "account.html", web.PageData{
|
|
Title: "Account settings",
|
|
Description: "Manage your Warpbox account.",
|
|
CurrentUser: a.authService.PublicUser(user),
|
|
Data: data,
|
|
})
|
|
}
|
|
|
|
func (a *App) ChangePassword(w http.ResponseWriter, r *http.Request) {
|
|
user, ok := a.requireUser(w, r)
|
|
if !ok || !a.validateCSRF(w, r) {
|
|
return
|
|
}
|
|
if err := r.ParseForm(); err != nil {
|
|
http.Redirect(w, r, "/account/settings", http.StatusSeeOther)
|
|
return
|
|
}
|
|
if !services.VerifyPasswordHash(user.PasswordHash, r.FormValue("current_password")) {
|
|
a.logger.Warn("password change failed current password", withRequestLogAttrs(r, "source", "user_activity", "severity", "warn", "code", 4422, "user_id", user.ID)...)
|
|
http.Redirect(w, r, "/account/settings", http.StatusSeeOther)
|
|
return
|
|
}
|
|
if err := a.authService.SetPassword(user.ID, r.FormValue("new_password")); err != nil {
|
|
a.logger.Warn("password change failed", withRequestLogAttrs(r, "source", "user_activity", "severity", "warn", "code", 4423, "user_id", user.ID, "error", err.Error())...)
|
|
http.Redirect(w, r, "/account/settings", http.StatusSeeOther)
|
|
return
|
|
}
|
|
a.logger.Info("password changed", withRequestLogAttrs(r, "source", "user_activity", "severity", "user_activity", "code", 2422, "user_id", user.ID)...)
|
|
http.Redirect(w, r, "/account/settings", http.StatusSeeOther)
|
|
}
|
|
|
|
func (a *App) renderAuth(w http.ResponseWriter, r *http.Request, status int, data authPageData) {
|
|
a.renderPage(w, r, status, "auth.html", web.PageData{
|
|
Title: "Account",
|
|
Description: "Sign in to Warpbox.",
|
|
Data: data,
|
|
})
|
|
}
|
|
|
|
func (a *App) loginAndRedirect(w http.ResponseWriter, r *http.Request, email, password, path string) {
|
|
_, token, err := a.authService.Login(email, password)
|
|
if err != nil {
|
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
|
return
|
|
}
|
|
a.setUserSessionCookie(w, r, token)
|
|
http.Redirect(w, r, path, http.StatusSeeOther)
|
|
}
|
|
|
|
func (a *App) currentUser(r *http.Request) (services.User, bool) {
|
|
user, ok, _ := a.currentUserWithAuthError(r)
|
|
return user, ok
|
|
}
|
|
|
|
func (a *App) currentUserWithAuthError(r *http.Request) (services.User, bool, error) {
|
|
// Personal access tokens via Authorization: Bearer act as their owning user.
|
|
// A bearer header is never set by browsers cross-site, so this path is not
|
|
// subject to CSRF and intentionally bypasses the session cookie.
|
|
if header := r.Header.Get("Authorization"); header != "" {
|
|
if raw, ok := strings.CutPrefix(header, "Bearer "); ok {
|
|
user, err := a.authService.UserForAPIToken(raw)
|
|
if err != nil {
|
|
return services.User{}, false, err
|
|
}
|
|
return user, true, nil
|
|
}
|
|
}
|
|
cookie, err := r.Cookie(userSessionCookieName)
|
|
if err != nil {
|
|
return services.User{}, false, nil
|
|
}
|
|
user, _, err := a.authService.UserForSession(cookie.Value)
|
|
if err != nil {
|
|
return services.User{}, false, nil
|
|
}
|
|
return user, true, nil
|
|
}
|
|
|
|
func (a *App) requireUser(w http.ResponseWriter, r *http.Request) (services.User, bool) {
|
|
user, ok := a.currentUser(r)
|
|
if ok {
|
|
return user, true
|
|
}
|
|
http.Redirect(w, r, "/login?next="+r.URL.Path, http.StatusSeeOther)
|
|
return services.User{}, false
|
|
}
|
|
|
|
func (a *App) setUserSessionCookie(w http.ResponseWriter, r *http.Request, token string) {
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: userSessionCookieName,
|
|
Value: token,
|
|
Path: "/",
|
|
HttpOnly: true,
|
|
SameSite: http.SameSiteLaxMode,
|
|
Secure: r.TLS != nil,
|
|
Expires: time.Now().Add(30 * 24 * time.Hour),
|
|
})
|
|
}
|
|
|
|
func (a *App) clearUserSessionCookie(w http.ResponseWriter) {
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: userSessionCookieName,
|
|
Value: "",
|
|
Path: "/",
|
|
HttpOnly: true,
|
|
SameSite: http.SameSiteLaxMode,
|
|
MaxAge: -1,
|
|
})
|
|
}
|
|
|
|
func safeReturnPath(path string) string {
|
|
if path == "" || path[0] != '/' || len(path) > 1 && path[1] == '/' {
|
|
return "/app"
|
|
}
|
|
return path
|
|
}
|