Initial commit
This commit is contained in:
commit
c578b91c2d
46 files changed
+3241
No files matched your search
@@ -0,0 +1,283 @@
|
||||
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)
|
||||
}
|
||||
Reference in new issue
Block a user