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) }