package app import ( "context" "database/sql" "encoding/json" "fmt" "log" "net/http" "net/url" "strings" "time" ) const ( sessionCookieName = "tapm_session" oauthCookieName = "tapm_oauth_state" ) type giteaTokenResponse struct { AccessToken string `json:"access_token"` TokenType string `json:"token_type"` } type giteaUser struct { Login string `json:"login"` FullName string `json:"full_name"` } func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { state, err := randomToken(24) if err != nil { http.Error(w, "unable to start sign-in", http.StatusInternalServerError) return } signedState := state + "." + signValue(s.cfg.CookieSecret, state) http.SetCookie(w, &http.Cookie{ Name: oauthCookieName, Value: signedState, Path: "/auth", MaxAge: 600, HttpOnly: true, Secure: s.cfg.CookieSecure, SameSite: http.SameSiteLaxMode, }) redirectURI := strings.TrimRight(s.cfg.PublicURL.String(), "/") + "/auth/callback" query := url.Values{ "client_id": {s.cfg.GiteaClientID}, "redirect_uri": {redirectURI}, "response_type": {"code"}, "scope": {"read:user"}, "state": {state}, } http.Redirect( w, r, joinURL(s.cfg.GiteaURL, "/login/oauth/authorize")+"?"+query.Encode(), http.StatusFound, ) } func (s *Server) handleCallback(w http.ResponseWriter, r *http.Request) { cookie, err := r.Cookie(oauthCookieName) if err != nil { http.Error(w, "sign-in state is missing", http.StatusBadRequest) return } parts := strings.Split(cookie.Value, ".") if len(parts) != 2 || !verifySignature(s.cfg.CookieSecret, parts[0], parts[1]) || r.URL.Query().Get("state") != parts[0] { http.Error(w, "sign-in state is invalid", http.StatusBadRequest) return } http.SetCookie(w, &http.Cookie{ Name: oauthCookieName, Value: "", Path: "/auth", MaxAge: -1, HttpOnly: true, Secure: s.cfg.CookieSecure, SameSite: http.SameSiteLaxMode, }) code := r.URL.Query().Get("code") if code == "" { http.Error(w, "authorization code is missing", http.StatusBadRequest) return } user, err := s.exchangeOAuthCode(r.Context(), code) if err != nil { log.Printf("Gitea OAuth code exchange failed: %v", err) http.Error(w, "Gitea sign-in failed", http.StatusBadGateway) return } if _, allowed := s.cfg.AllowedGiteaUsers[strings.ToLower(user.Login)]; !allowed { _ = s.audit(r.Context(), "login_denied", user.Login, nil, "", "", s.clientIP(r), "user is not allowed") http.Error(w, "this Gitea user is not authorized", http.StatusForbidden) return } sessionToken, err := randomToken(32) if err != nil { http.Error(w, "unable to create session", http.StatusInternalServerError) return } csrfToken, err := randomToken(24) if err != nil { http.Error(w, "unable to create session", http.StatusInternalServerError) return } tokenHash := hashValue(sessionToken) displayName := strings.TrimSpace(user.FullName) if displayName == "" { displayName = user.Login } expiresAt := time.Now().UTC().Add(12 * time.Hour) _, err = s.db.ExecContext( r.Context(), `INSERT INTO technician_sessions (token_hash, csrf_token, gitea_login, display_name, expires_at) VALUES (?, ?, ?, ?, ?)`, tokenHash[:], csrfToken, user.Login, displayName, expiresAt, ) if err != nil { http.Error(w, "unable to save session", http.StatusInternalServerError) return } http.SetCookie(w, &http.Cookie{ Name: sessionCookieName, Value: sessionToken, Path: "/", Expires: expiresAt, MaxAge: int(time.Until(expiresAt).Seconds()), HttpOnly: true, Secure: s.cfg.CookieSecure, SameSite: http.SameSiteLaxMode, }) _ = s.audit(r.Context(), "login_succeeded", user.Login, nil, "", "", s.clientIP(r), "") http.Redirect(w, r, "/portal", http.StatusSeeOther) } func (s *Server) exchangeOAuthCode(ctx context.Context, code string) (giteaUser, error) { var user giteaUser redirectURI := strings.TrimRight(s.cfg.PublicURL.String(), "/") + "/auth/callback" form := url.Values{ "client_id": {s.cfg.GiteaClientID}, "client_secret": {s.cfg.GiteaClientSecret}, "code": {code}, "grant_type": {"authorization_code"}, "redirect_uri": {redirectURI}, } request, err := http.NewRequestWithContext( ctx, http.MethodPost, joinURL(s.cfg.GiteaInternalURL, "/login/oauth/access_token"), strings.NewReader(form.Encode()), ) if err != nil { return user, err } request.Header.Set("Accept", "application/json") request.Header.Set("Content-Type", "application/x-www-form-urlencoded") response, err := s.client.Do(request) if err != nil { return user, err } defer response.Body.Close() if response.StatusCode != http.StatusOK { return user, fmt.Errorf("token endpoint returned %s", response.Status) } var token giteaTokenResponse if err := json.NewDecoder(response.Body).Decode(&token); err != nil { return user, err } if token.AccessToken == "" { return user, fmt.Errorf("token endpoint returned an empty token") } request, err = http.NewRequestWithContext( ctx, http.MethodGet, joinURL(s.cfg.GiteaInternalURL, "/api/v1/user"), nil, ) if err != nil { return user, err } request.Header.Set("Authorization", "token "+token.AccessToken) response, err = s.client.Do(request) if err != nil { return user, err } defer response.Body.Close() if response.StatusCode != http.StatusOK { return user, fmt.Errorf("user endpoint returned %s", response.Status) } if err := json.NewDecoder(response.Body).Decode(&user); err != nil { return user, err } if user.Login == "" { return user, fmt.Errorf("user endpoint returned an empty login") } return user, nil } func (s *Server) currentTechnician(r *http.Request) (*technician, error) { cookie, err := r.Cookie(sessionCookieName) if err != nil { return nil, err } tokenHash := hashValue(cookie.Value) var tech technician err = s.db.QueryRowContext( r.Context(), `SELECT gitea_login, display_name, csrf_token FROM technician_sessions WHERE token_hash = ? AND expires_at > CURRENT_TIMESTAMP`, tokenHash[:], ).Scan(&tech.Login, &tech.DisplayName, &tech.CSRFToken) if err != nil { return nil, err } _, _ = s.db.ExecContext( r.Context(), `UPDATE technician_sessions SET last_seen_at = CURRENT_TIMESTAMP WHERE token_hash = ?`, tokenHash[:], ) return &tech, nil } func (s *Server) requireTechnician(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { tech, err := s.currentTechnician(r) if err != nil { if !errorsIsNoRowsOrCookie(err) { http.Error(w, "unable to validate session", http.StatusInternalServerError) return } http.Redirect(w, r, "/", http.StatusSeeOther) return } if r.Method != http.MethodGet { csrfToken := r.Header.Get("X-CSRF-Token") if csrfToken == "" { csrfToken = r.FormValue("csrf_token") } if csrfToken != tech.CSRFToken { http.Error(w, "invalid request token", http.StatusForbidden) return } } next(w, r) } } func errorsIsNoRowsOrCookie(err error) bool { return err == http.ErrNoCookie || err == sql.ErrNoRows } func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { tech, err := s.currentTechnician(r) if err != nil { http.Redirect(w, r, "/", http.StatusSeeOther) return } if r.FormValue("csrf_token") != tech.CSRFToken { http.Error(w, "invalid request token", http.StatusForbidden) return } if cookie, err := r.Cookie(sessionCookieName); err == nil { tokenHash := hashValue(cookie.Value) _, _ = s.db.ExecContext(r.Context(), `DELETE FROM technician_sessions WHERE token_hash = ?`, tokenHash[:]) } http.SetCookie(w, &http.Cookie{ Name: sessionCookieName, Value: "", Path: "/", MaxAge: -1, HttpOnly: true, Secure: s.cfg.CookieSecure, SameSite: http.SameSiteLaxMode, }) http.Redirect(w, r, "/", http.StatusSeeOther) }