271 lines
8.4 KiB
Go
271 lines
8.4 KiB
Go
package web
|
|
|
|
import (
|
|
"database/sql"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"uPortal/internal/auth"
|
|
"uPortal/internal/jitsi"
|
|
"uPortal/internal/log"
|
|
"uPortal/internal/template"
|
|
"uPortal/internal/user"
|
|
)
|
|
|
|
// Cookie names and the CSRF form field name. These match the layout
|
|
// template (templates/layouts/csrf.html) and the auth package helpers.
|
|
const (
|
|
SessionCookieName = "sessiontoken"
|
|
CsrfCookieName = "csrftoken"
|
|
CsrfFieldName = "formtoken"
|
|
)
|
|
|
|
// CookieSecure controls the Secure attribute of the session cookie.
|
|
// It is disabled by default so the app works over plain http://127.0.0.1
|
|
// during development (browsers drop Secure cookies over plain http).
|
|
// Production deployments behind HTTPS must flip this to true.
|
|
var CookieSecure = false
|
|
|
|
// ViewData is the data bag passed to every page template. It carries the
|
|
// CSRF token, the current user, and any collections the pages display.
|
|
type ViewData struct {
|
|
Title string
|
|
IsSuperUser bool
|
|
FormToken string
|
|
Error string
|
|
User *user.User
|
|
Users []user.User
|
|
Sessions []auth.Session
|
|
Invites []auth.Invite
|
|
Redirect string
|
|
}
|
|
|
|
type WebService struct {
|
|
users *user.Repository
|
|
auths *auth.Repository
|
|
views *template.Service
|
|
db *sql.DB
|
|
jitsiCfg *jitsi.Config
|
|
trustedDomains []string
|
|
log *log.Logger
|
|
}
|
|
|
|
// New returns a WebService ready to be registered onto an http.ServeMux.
|
|
func New(users *user.Repository, auths *auth.Repository, views *template.Service, db *sql.DB, jitsiCfg *jitsi.Config, trustedDomains []string, logger *log.Logger) *WebService {
|
|
return &WebService{
|
|
users: users,
|
|
auths: auths,
|
|
views: views,
|
|
db: db,
|
|
jitsiCfg: jitsiCfg,
|
|
trustedDomains: trustedDomains,
|
|
log: logger,
|
|
}
|
|
}
|
|
|
|
// isTrustedTarget reports whether target may skip the /redirect warning page.
|
|
// Relative paths, empty hostnames and unknown hosts are never trusted;
|
|
// localhost and 127.0.0.1 are always trusted.
|
|
func (s *WebService) isTrustedTarget(target string) bool {
|
|
// Handle URLs without a scheme (e.g. "meet.jit.si/room").
|
|
if !strings.Contains(target, "://") {
|
|
target = "https://" + target
|
|
}
|
|
u, err := url.Parse(target)
|
|
if err != nil || u.Hostname() == "" {
|
|
return false
|
|
}
|
|
host := u.Hostname()
|
|
|
|
// Loopback addresses are always trusted for full URL redirects.
|
|
if host == "localhost" || host == "127.0.0.1" {
|
|
return true
|
|
}
|
|
|
|
for _, domain := range s.trustedDomains {
|
|
if strings.EqualFold(host, strings.TrimSpace(domain)) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// Register wires every route (method + path) onto the mux.
|
|
func (s *WebService) Register(mux *http.ServeMux) {
|
|
// Helper to wrap every handler with logging middleware.
|
|
handle := func(pattern string, handler http.HandlerFunc) {
|
|
mux.HandleFunc(pattern, s.loggingMiddleware(handler))
|
|
}
|
|
|
|
// Password
|
|
handle("GET /profile/password", s.requireAuth(s.HandleProfilePassword))
|
|
handle("POST /profile/password", s.requireAuth(s.HandleProfilePassword))
|
|
|
|
// Profile
|
|
// "/{$}" matches only the root path exactly; any other unmatched path
|
|
// falls through to the mux default 404 instead of being served here.
|
|
handle("GET /{$}", s.requireAuth(s.HandleProfile))
|
|
handle("GET /profile", s.requireAuth(s.HandleProfile))
|
|
handle("GET /profile/edit", s.requireAuth(s.HandleProfileEdit))
|
|
handle("POST /profile/edit", s.requireAuth(s.HandleProfileEdit))
|
|
handle("GET /avatar/{id}", s.HandleAvatar)
|
|
|
|
// Auth
|
|
handle("GET /signin", s.HandleSignin)
|
|
handle("POST /signin", s.HandleSignin)
|
|
handle("GET /signup", s.HandleSignup)
|
|
handle("POST /signup", s.HandleSignup)
|
|
handle("GET /signout", s.requireAuth(s.HandleSignout))
|
|
handle("POST /signout", s.requireAuth(s.HandleSignout))
|
|
|
|
// External redirect warning
|
|
handle("GET /redirect", s.HandleExternalRedirect)
|
|
|
|
// Sessions
|
|
handle("GET /sessions", s.requireAuth(s.HandleSessions))
|
|
handle("POST /sessions/end", s.requireAuth(s.HandleSessionEnd))
|
|
handle("POST /sessions/end-all", s.requireAuth(s.HandleSessionEndAll))
|
|
|
|
// Users (super user only)
|
|
handle("GET /users", s.requireSuperUser(s.HandleUsers))
|
|
handle("POST /users/reset-password", s.requireSuperUser(s.HandleUserResetPassword))
|
|
handle("POST /users/end-sessions", s.requireSuperUser(s.HandleUserEndSessions))
|
|
|
|
// Invites (super user only)
|
|
handle("GET /invites", s.requireSuperUser(s.HandleInvites))
|
|
handle("GET /invites/create", s.requireSuperUser(s.HandleInviteCreate))
|
|
handle("POST /invites/create", s.requireSuperUser(s.HandleInviteCreate))
|
|
handle("POST /invites/delete", s.requireSuperUser(s.HandleInviteDelete))
|
|
|
|
// Jitsi
|
|
handle("GET /jitsi", s.requireAuth(s.HandleJitsi))
|
|
handle("POST /jitsi", s.requireAuth(s.HandleJitsi))
|
|
handle("GET /jitsi/go", s.requireAuth(s.HandleJitsiGo))
|
|
|
|
// nginx auth_request endpoint — validates session, returns identity headers.
|
|
handle("GET /auth", s.HandleAuthRequest)
|
|
}
|
|
|
|
// loggingMiddleware logs every HTTP request. Static file endpoints are skipped.
|
|
func (s *WebService) loggingMiddleware(next http.HandlerFunc) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
// Skip logging for static files and health checks.
|
|
if strings.HasPrefix(r.URL.Path, "/static/") {
|
|
next(w, r)
|
|
return
|
|
}
|
|
|
|
start := time.Now()
|
|
|
|
// Wrap the ResponseWriter to capture the status code.
|
|
lrw := &loggingResponseWriter{ResponseWriter: w, statusCode: http.StatusOK}
|
|
next(lrw, r)
|
|
|
|
query := r.URL.RawQuery
|
|
path := r.URL.Path
|
|
if query != "" {
|
|
path = path + "?" + query
|
|
}
|
|
|
|
s.log.Info("%s %s -> %d in %s",
|
|
r.Method, path, lrw.statusCode, time.Since(start).Round(time.Millisecond))
|
|
}
|
|
}
|
|
|
|
type loggingResponseWriter struct {
|
|
http.ResponseWriter
|
|
statusCode int
|
|
}
|
|
|
|
func (lrw *loggingResponseWriter) WriteHeader(code int) {
|
|
lrw.statusCode = code
|
|
lrw.ResponseWriter.WriteHeader(code)
|
|
}
|
|
|
|
// render mints a CSRF token, fills ViewData defaults, and writes the response.
|
|
func (s *WebService) render(w http.ResponseWriter, r *http.Request, page string, data *ViewData) {
|
|
token, err := auth.SetCsrfCookie(w, r, CsrfCookieName)
|
|
if err != nil {
|
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if data == nil {
|
|
data = &ViewData{}
|
|
}
|
|
data.FormToken = token
|
|
if data.Title == "" {
|
|
data.Title = page
|
|
}
|
|
if data.User != nil && data.User.SuperUser {
|
|
data.IsSuperUser = true
|
|
}
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
if err := s.views.Render(w, page, data); err != nil {
|
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
|
}
|
|
}
|
|
|
|
// getUserInfo reads the session cookie and looks up the associated user.
|
|
func (s *WebService) getUserInfo(r *http.Request) (*user.User, error) {
|
|
cookie, err := r.Cookie(SessionCookieName)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
session, err := s.auths.GetSessionByToken(r.Context(), cookie.Value)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !session.IsValid() {
|
|
return nil, fmt.Errorf("session expired")
|
|
}
|
|
return s.users.GetUserById(r.Context(), session.UserId)
|
|
}
|
|
|
|
func (s *WebService) currentUser(r *http.Request) (*user.User, error) {
|
|
return s.getUserInfo(r)
|
|
}
|
|
|
|
// basicViewData builds a ViewData with the current user and a CSRF token.
|
|
func (s *WebService) basicViewData(w http.ResponseWriter, r *http.Request, title string) (*ViewData, bool) {
|
|
user, err := s.getUserInfo(r)
|
|
if err != nil {
|
|
return nil, false
|
|
}
|
|
token, err := auth.SetCsrfCookie(w, r, CsrfCookieName)
|
|
if err != nil {
|
|
return nil, false
|
|
}
|
|
return &ViewData{Title: title, User: user, IsSuperUser: user.SuperUser, FormToken: token}, true
|
|
}
|
|
|
|
// requireAuth redirects unauthenticated visitors to /signin with a ?redirect=
|
|
// parameter so they return to their intended page after login.
|
|
func (s *WebService) requireAuth(next http.HandlerFunc) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
if _, err := s.currentUser(r); err != nil {
|
|
redirectTo := "/signin"
|
|
if r.URL.Path != "/" && r.URL.Path != "" {
|
|
redirectTo = "/signin?redirect=" + url.PathEscape(r.URL.RequestURI())
|
|
}
|
|
http.Redirect(w, r, redirectTo, http.StatusSeeOther)
|
|
return
|
|
}
|
|
next(w, r)
|
|
}
|
|
}
|
|
|
|
// requireSuperUser wraps a handler, requiring an authenticated super user.
|
|
func (s *WebService) requireSuperUser(next http.HandlerFunc) http.HandlerFunc {
|
|
return s.requireAuth(func(w http.ResponseWriter, r *http.Request) {
|
|
u, err := s.currentUser(r)
|
|
if err != nil || u == nil || !u.SuperUser {
|
|
http.Error(w, "Forbidden", http.StatusForbidden)
|
|
return
|
|
}
|
|
next(w, r)
|
|
})
|
|
}
|