Modify the authentication handler to return an unauthorized error when an invalid or disabled bearer token is provided, rather than silently falling back to an anonymous request. This ensures that clients attempting to authenticate but failing (due to expired, malformed, or disabled tokens) are explicitly notified of the auth failure instead of proceeding anonymously. True anonymous requests without any Authorization header remain supported.
320 lines
10 KiB
Go
320 lines
10 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.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.renderAuth(w, r, http.StatusBadRequest, authPageData{Mode: "register", Error: err.Error()})
|
|
return
|
|
}
|
|
a.logger.Info("first admin created", "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.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.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", "source", "auth", "severity", "warn", "code", 4401, "email", r.FormValue("email"))
|
|
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", "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 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.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.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.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", "source", "auth", "severity", "user_activity", "code", 2403, "user_id", user.ID)
|
|
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.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", "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", "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", "source", "user_activity", "severity", "warn", "code", 4421, "user_id", user.ID, "error", err.Error())
|
|
}
|
|
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")) {
|
|
http.Redirect(w, r, "/account/settings", http.StatusSeeOther)
|
|
return
|
|
}
|
|
if err := a.authService.SetPassword(user.ID, r.FormValue("new_password")); err != nil {
|
|
http.Redirect(w, r, "/account/settings", http.StatusSeeOther)
|
|
return
|
|
}
|
|
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
|
|
}
|