Files
uPortal/internal/web/auth.go
T

283 lines
9.0 KiB
Go
Raw Normal View History

2026-10-15 23:25:46 +03:00
package web
import (
"net/http"
"net/url"
"strings"
"time"
"uPortal/internal/auth"
)
// HandleSignin renders the sign-in page on GET and authenticates on POST.
func (s *WebService) HandleSignin(w http.ResponseWriter, r *http.Request) {
// If already authenticated, redirect
if _, err := s.currentUser(r); err == nil {
redirectTo := "/"
if r.URL.Query().Get("redirect") != "" {
redirectTo = r.URL.Query().Get("redirect")
}
// For relative paths, redirect directly; for external URLs, route
// through the warning page.
if strings.HasPrefix(redirectTo, "/") && !strings.Contains(redirectTo, "://") {
http.Redirect(w, r, redirectTo, http.StatusSeeOther)
} else {
http.Redirect(w, r, "/redirect?to="+url.QueryEscape(redirectTo), http.StatusSeeOther)
}
return
}
switch r.Method {
case http.MethodGet:
redirect := r.URL.Query().Get("redirect")
s.render(w, r, "auth/signin.html", &ViewData{Redirect: redirect})
case http.MethodPost:
if err := auth.ValidateCsrf(r, CsrfCookieName, CsrfFieldName); err != nil {
s.render(w, r, "auth/signin.html", &ViewData{Error: "Invalid form, please try again"})
return
}
username := r.FormValue("username")
password := r.FormValue("password")
remember := r.FormValue("remember")
user, err := s.users.GetUserByLogin(r.Context(), username)
if err != nil || !auth.CheckPasswordHash(password, user.Password) {
s.render(w, r, "auth/signin.html", &ViewData{Error: "Invalid username or password"})
return
}
token, device, address, err := auth.CreateSession(r, user.Id)
if err != nil {
s.render(w, r, "auth/signin.html", &ViewData{Error: "Could not create session"})
return
}
// Session expires 24h from now; "remember" extends it to 30 days.
expiresAt := time.Now().UTC().Add(24 * time.Hour)
maxAge := 0 // zero => session cookie (until browser closes)
if remember == "on" {
expiresAt = time.Now().UTC().Add(30 * 24 * time.Hour)
maxAge = 30 * 24 * 3600
}
if _, err := s.auths.CreateSession(r.Context(), token, user.Id, device, address, expiresAt); err != nil {
s.render(w, r, "auth/signin.html", &ViewData{Error: "Could not save session"})
return
}
cookie := &http.Cookie{
Name: SessionCookieName,
Value: token,
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Secure: CookieSecure,
MaxAge: maxAge,
}
http.SetCookie(w, cookie)
redirectTo := "/"
if r.FormValue("redirect") != "" {
redirectTo = r.FormValue("redirect")
} else if r.URL.Query().Get("redirect") != "" {
redirectTo = r.URL.Query().Get("redirect")
}
// For relative paths, redirect directly; for external URLs, route
// through the warning page unless the domain is trusted.
if strings.HasPrefix(redirectTo, "/") && !strings.Contains(redirectTo, "://") {
http.Redirect(w, r, redirectTo, http.StatusSeeOther)
} else if s.isTrustedTarget(redirectTo) {
http.Redirect(w, r, redirectTo, http.StatusSeeOther)
} else {
http.Redirect(w, r, "/redirect?to="+url.QueryEscape(redirectTo), http.StatusSeeOther)
}
}
}
// HandleSignup renders the sign-up page on GET and creates an account on POST.
// Invite codes are one-time-use unless they carry multiple uses.
func (s *WebService) HandleSignup(w http.ResponseWriter, r *http.Request) {
// If already authenticated, redirect
if _, err := s.currentUser(r); err == nil {
redirectTo := "/"
if r.URL.Query().Get("redirect") != "" {
redirectTo = r.URL.Query().Get("redirect")
}
// For relative paths, redirect directly; for external URLs, route
// through the warning page.
if strings.HasPrefix(redirectTo, "/") && !strings.Contains(redirectTo, "://") {
http.Redirect(w, r, redirectTo, http.StatusSeeOther)
} else {
http.Redirect(w, r, "/redirect?to="+url.QueryEscape(redirectTo), http.StatusSeeOther)
}
return
}
switch r.Method {
case http.MethodGet:
redirect := r.URL.Query().Get("redirect")
s.render(w, r, "auth/signup.html", &ViewData{Redirect: redirect})
case http.MethodPost:
if err := auth.ValidateCsrf(r, CsrfCookieName, CsrfFieldName); err != nil {
s.render(w, r, "auth/signup.html", &ViewData{Error: "Invalid form, please try again"})
return
}
username := r.FormValue("username")
password := r.FormValue("password")
confirm := r.FormValue("confirm")
inviteToken := r.FormValue("invite")
if password != confirm {
s.render(w, r, "auth/signup.html", &ViewData{Error: "Passwords do not match"})
return
}
if len(password) < 6 {
s.render(w, r, "auth/signup.html", &ViewData{Error: "Password must be at least 6 characters"})
return
}
if inviteToken == "" {
s.render(w, r, "auth/signup.html", &ViewData{Error: "Invite code is required"})
return
}
invite, err := s.auths.GetInviteByToken(r.Context(), inviteToken)
if err != nil {
s.render(w, r, "auth/signup.html", &ViewData{Error: "Invalid invite code"})
return
}
if !invite.IsValid() {
s.render(w, r, "auth/signup.html", &ViewData{Error: "Invite code has expired or been used up"})
return
}
// Decrement uses; delete the invite once exhausted.
invite.Uses--
if invite.Uses <= 0 {
if err := s.auths.DeleteInvite(r.Context(), invite.Token); err != nil {
s.render(w, r, "auth/signup.html", &ViewData{Error: "Could not process invite"})
return
}
} else {
if err := s.auths.UpdateInvite(r.Context(), invite); err != nil {
s.render(w, r, "auth/signup.html", &ViewData{Error: "Could not process invite"})
return
}
}
// CreateUser stores the password as-is, so hash it first.
hashed, err := auth.HashPassword(password)
if err != nil {
s.render(w, r, "auth/signup.html", &ViewData{Error: "Could not create account"})
return
}
user, err := s.users.CreateUser(r.Context(), username, hashed)
if err != nil {
s.render(w, r, "auth/signup.html", &ViewData{Error: "Username already taken"})
return
}
// Auto-login.
token, device, address, err := auth.CreateSession(r, user.Id)
if err != nil {
s.render(w, r, "auth/signup.html", &ViewData{Error: "Could not create session"})
return
}
expiresAt := time.Now().UTC().Add(24 * time.Hour)
if _, err := s.auths.CreateSession(r.Context(), token, user.Id, device, address, expiresAt); err != nil {
s.render(w, r, "auth/signup.html", &ViewData{Error: "Could not save session"})
return
}
cookie := &http.Cookie{
Name: SessionCookieName,
Value: token,
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Secure: CookieSecure,
}
http.SetCookie(w, cookie)
redirectTo := "/"
if r.FormValue("redirect") != "" {
redirectTo = r.FormValue("redirect")
} else if r.URL.Query().Get("redirect") != "" {
redirectTo = r.URL.Query().Get("redirect")
}
// For relative paths, redirect directly; for external URLs, route
// through the warning page unless the domain is trusted.
if strings.HasPrefix(redirectTo, "/") && !strings.Contains(redirectTo, "://") {
http.Redirect(w, r, redirectTo, http.StatusSeeOther)
} else if s.isTrustedTarget(redirectTo) {
http.Redirect(w, r, redirectTo, http.StatusSeeOther)
} else {
http.Redirect(w, r, "/redirect?to="+url.QueryEscape(redirectTo), http.StatusSeeOther)
}
}
}
// HandleSignout renders a confirmation page on GET and deletes the session on POST.
// It is wrapped in requireAuth for the POST route; the GET route may show the page
// even for anonymous visitors (the confirm form just redirects to /signin).
func (s *WebService) HandleSignout(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
data, ok := s.basicViewData(w, r, "Sign Out")
if !ok {
http.Redirect(w, r, "/signin?redirect="+url.PathEscape(r.URL.RequestURI()), http.StatusSeeOther)
return
}
s.render(w, r, "auth/signout.html", data)
case http.MethodPost:
if err := auth.ValidateCsrf(r, CsrfCookieName, CsrfFieldName); err != nil {
s.render(w, r, "auth/signout.html", &ViewData{Error: "Invalid form, please try again"})
return
}
if cookie, err := r.Cookie(SessionCookieName); err == nil {
_ = s.auths.DeleteSession(r.Context(), cookie.Value)
}
http.SetCookie(w, &http.Cookie{
Name: SessionCookieName,
Value: "",
Path: "/",
HttpOnly: true,
MaxAge: -1,
})
http.Redirect(w, r, "/signin", http.StatusSeeOther)
}
}
// HandleExternalRedirect shows a warning page when the user is about to
// leave the site via an external redirect.
func (s *WebService) HandleExternalRedirect(w http.ResponseWriter, r *http.Request) {
target := r.URL.Query().Get("to")
if target == "" {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
// Trusted targets skip the warning and are redirected directly.
if s.isTrustedTarget(target) {
http.Redirect(w, r, target, http.StatusSeeOther)
return
}
data, ok := s.basicViewData(w, r, "External Redirect")
if !ok {
// Not authenticated is fine — just show the warning without nav
data = &ViewData{Title: "External Redirect"}
token, _ := auth.SetCsrfCookie(w, r, CsrfCookieName)
data.FormToken = token
}
data.Redirect = target
s.render(w, r, "redirect/warning.html", data)
}