283 lines
9.0 KiB
Go
283 lines
9.0 KiB
Go
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)
|
||
|
|
}
|