diff --git a/.gitignore b/.gitignore index a7b9edf..e6b12db 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,7 @@ go.work.sum # env file .env +.env.remote # key files *.pem diff --git a/go.mod b/go.mod index 0521f55..e85d195 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ require ( ) require ( + github.com/avct/uasurfer v0.0.0-20250506104815-f2613aa2d406 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/dustin/go-humanize v1.0.1 // indirect diff --git a/go.sum b/go.sum index 50238e5..4cf81f0 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/avct/uasurfer v0.0.0-20250506104815-f2613aa2d406 h1:5/KfwL9TS8yNtUSunutqifcSC8rdX9PNdvbSsw/X/lQ= +github.com/avct/uasurfer v0.0.0-20250506104815-f2613aa2d406/go.mod h1:s+GCtuP4kZNxh1WGoqdWI1+PbluBcycrMMWuKQ9e5Nk= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= diff --git a/internal/admin/apiservices.go b/internal/admin/apiservices.go index 4529d6b..5b38391 100644 --- a/internal/admin/apiservices.go +++ b/internal/admin/apiservices.go @@ -4,45 +4,15 @@ import ( "encoding/json" "log" "net/http" - "time" "gitea.local/admin/hspguard/internal/repository" + "gitea.local/admin/hspguard/internal/types" "gitea.local/admin/hspguard/internal/util" "gitea.local/admin/hspguard/internal/web" "github.com/go-chi/chi/v5" "github.com/google/uuid" ) -type ApiServiceDTO struct { - ID uuid.UUID `json:"id"` - ClientID string `json:"client_id"` - Name string `json:"name"` - Description *string `json:"description"` - IconUrl *string `json:"icon_url"` - RedirectUris []string `json:"redirect_uris"` - Scopes []string `json:"scopes"` - GrantTypes []string `json:"grant_types"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - IsActive bool `json:"is_active"` -} - -func NewApiServiceDTO(service repository.ApiService) ApiServiceDTO { - return ApiServiceDTO{ - ID: service.ID, - ClientID: service.ClientID, - Name: service.Name, - Description: service.Description, - IconUrl: service.IconUrl, - RedirectUris: service.RedirectUris, - Scopes: service.Scopes, - GrantTypes: service.GrantTypes, - CreatedAt: service.CreatedAt, - UpdatedAt: service.UpdatedAt, - IsActive: service.IsActive, - } -} - func (h *AdminHandler) GetApiServices(w http.ResponseWriter, r *http.Request) { services, err := h.repo.ListApiServices(r.Context()) if err != nil { @@ -51,15 +21,15 @@ func (h *AdminHandler) GetApiServices(w http.ResponseWriter, r *http.Request) { return } - apiServices := make([]ApiServiceDTO, 0) + apiServices := make([]types.ApiServiceDTO, 0) for _, service := range services { - apiServices = append(apiServices, NewApiServiceDTO(service)) + apiServices = append(apiServices, types.NewApiServiceDTO(service)) } type Response struct { - Items []ApiServiceDTO `json:"items"` - Count int `json:"count"` + Items []types.ApiServiceDTO `json:"items"` + Count int `json:"count"` } encoder := json.NewEncoder(w) @@ -146,7 +116,7 @@ func (h *AdminHandler) AddApiService(w http.ResponseWriter, r *http.Request) { service.ClientSecret = clientSecret type Response struct { - Service ApiServiceDTO `json:"service"` + Service types.ApiServiceDTO `json:"service"` Credentials ApiServiceCredentials `json:"credentials"` } @@ -155,7 +125,7 @@ func (h *AdminHandler) AddApiService(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") if err := encoder.Encode(Response{ - Service: NewApiServiceDTO(service), + Service: types.NewApiServiceDTO(service), Credentials: ApiServiceCredentials{ ClientId: service.ClientID, ClientSecret: service.ClientSecret, @@ -183,7 +153,7 @@ func (h *AdminHandler) GetApiService(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") - if err := encoder.Encode(NewApiServiceDTO(service)); err != nil { + if err := encoder.Encode(types.NewApiServiceDTO(service)); err != nil { web.Error(w, "failed to encode response", http.StatusInternalServerError) } } @@ -201,7 +171,7 @@ func (h *AdminHandler) GetApiServiceCID(w http.ResponseWriter, r *http.Request) w.Header().Set("Content-Type", "application/json") - if err := encoder.Encode(NewApiServiceDTO(service)); err != nil { + if err := encoder.Encode(types.NewApiServiceDTO(service)); err != nil { web.Error(w, "failed to encode response", http.StatusInternalServerError) } } @@ -303,7 +273,7 @@ func (h *AdminHandler) UpdateApiService(w http.ResponseWriter, r *http.Request) w.Header().Set("Content-Type", "application/json") - if err := encoder.Encode(NewApiServiceDTO(updated)); err != nil { + if err := encoder.Encode(types.NewApiServiceDTO(updated)); err != nil { web.Error(w, "failed to send updated api service", http.StatusInternalServerError) } } diff --git a/internal/admin/routes.go b/internal/admin/routes.go index 73a22e1..bb679ff 100644 --- a/internal/admin/routes.go +++ b/internal/admin/routes.go @@ -21,7 +21,7 @@ func New(repo *repository.Queries, cfg *config.AppConfig) *AdminHandler { func (h *AdminHandler) RegisterRoutes(router chi.Router) { router.Route("/admin", func(r chi.Router) { - authMiddleware := imiddleware.NewAuthMiddleware(h.cfg) + authMiddleware := imiddleware.NewAuthMiddleware(h.cfg, h.repo) adminMiddleware := imiddleware.NewAdminMiddleware(h.repo) r.Use(authMiddleware.Runner, adminMiddleware.Runner) @@ -35,6 +35,12 @@ func (h *AdminHandler) RegisterRoutes(router chi.Router) { r.Get("/users", h.GetUsers) r.Post("/users", h.CreateUser) r.Get("/users/{id}", h.GetUser) + + r.Get("/user-sessions", h.GetUserSessions) + r.Patch("/user-sessions/revoke/{id}", h.RevokeUserSession) + + r.Get("/service-sessions", h.GetServiceSessions) + r.Patch("/service-sessions/revoke/{id}", h.RevokeUserSession) }) router.Get("/api-services/client/{client_id}", h.GetApiServiceCID) diff --git a/internal/admin/sessions.go b/internal/admin/sessions.go new file mode 100644 index 0000000..0a9bd1d --- /dev/null +++ b/internal/admin/sessions.go @@ -0,0 +1,182 @@ +package admin + +import ( + "encoding/json" + "log" + "math" + "net/http" + "strconv" + + "gitea.local/admin/hspguard/internal/repository" + "gitea.local/admin/hspguard/internal/types" + "gitea.local/admin/hspguard/internal/web" + "github.com/go-chi/chi/v5" + "github.com/google/uuid" +) + +type GetSessionsParams struct { + PageSize int `json:"size"` + Page int `json:"page"` + // TODO: More filtering possibilities like onlyActive, expired, not-expired etc. +} + +func (h *AdminHandler) GetUserSessions(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + + params := GetSessionsParams{} + + if pageSize, err := strconv.Atoi(q.Get("size")); err == nil { + params.PageSize = pageSize + } else { + params.PageSize = 15 + } + + if page, err := strconv.Atoi(q.Get("page")); err == nil { + params.Page = page + } else { + web.Error(w, "page is required", http.StatusBadRequest) + return + } + + sessions, err := h.repo.GetUserSessions(r.Context(), repository.GetUserSessionsParams{ + Limit: int32(params.PageSize), + Offset: int32(params.Page-1) * int32(params.PageSize), + }) + if err != nil { + log.Println("ERR: Failed to read user sessions from db:", err) + web.Error(w, "failed to retrieve sessions", http.StatusInternalServerError) + return + } + + totalSessions, err := h.repo.GetUserSessionsCount(r.Context()) + if err != nil { + log.Println("ERR: Failed to get total count of user sessions:", err) + web.Error(w, "failed to retrieve sessions", http.StatusInternalServerError) + return + } + + mapped := make([]*types.UserSessionDTO, 0) + + for _, session := range sessions { + mapped = append(mapped, types.NewUserSessionDTO(&session)) + } + + type Response struct { + Items []*types.UserSessionDTO `json:"items"` + Page int `json:"page"` + TotalPages int `json:"total_pages"` + } + + response := Response{ + Items: mapped, + Page: params.Page, + TotalPages: int(math.Ceil(float64(totalSessions) / float64(params.PageSize))), + } + + w.Header().Set("Content-Type", "application/json") + + if err := json.NewEncoder(w).Encode(response); err != nil { + log.Println("ERR: Failed to encode sessions in response:", err) + web.Error(w, "failed to encode sessions", http.StatusInternalServerError) + return + } +} + +func (h *AdminHandler) RevokeUserSession(w http.ResponseWriter, r *http.Request) { + sessionId := chi.URLParam(r, "id") + parsed, err := uuid.Parse(sessionId) + if err != nil { + web.Error(w, "provided service id is not valid", http.StatusBadRequest) + return + } + + if err := h.repo.RevokeUserSession(r.Context(), parsed); err != nil { + log.Println("ERR: Failed to revoke user session:", err) + web.Error(w, "failed to revoke user session", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + + w.WriteHeader(http.StatusOK) + w.Write([]byte("{\"success\":true}")) +} + +func (h *AdminHandler) GetServiceSessions(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + + params := GetSessionsParams{} + + if pageSize, err := strconv.Atoi(q.Get("size")); err == nil { + params.PageSize = pageSize + } else { + params.PageSize = 15 + } + + if page, err := strconv.Atoi(q.Get("page")); err == nil { + params.Page = page + } else { + web.Error(w, "page is required", http.StatusBadRequest) + return + } + + sessions, err := h.repo.GetServiceSessions(r.Context(), repository.GetServiceSessionsParams{ + Limit: int32(params.PageSize), + Offset: int32(params.Page-1) * int32(params.PageSize), + }) + if err != nil { + log.Println("ERR: Failed to read api sessions from db:", err) + web.Error(w, "failed to retrieve sessions", http.StatusInternalServerError) + return + } + + totalSessions, err := h.repo.GetServiceSessionsCount(r.Context()) + if err != nil { + log.Println("ERR: Failed to get total count of service sessions:", err) + web.Error(w, "failed to retrieve sessions", http.StatusInternalServerError) + return + } + + mapped := make([]*types.ServiceSessionDTO, 0) + + for _, session := range sessions { + mapped = append(mapped, types.NewServiceSessionDTO(&session)) + } + + type Response struct { + Items []*types.ServiceSessionDTO `json:"items"` + Page int `json:"page"` + TotalPages int `json:"total_pages"` + } + + response := Response{ + Items: mapped, + Page: params.Page, + TotalPages: int(math.Ceil(float64(totalSessions) / float64(params.PageSize))), + } + + w.Header().Set("Content-Type", "application/json") + + if err := json.NewEncoder(w).Encode(response); err != nil { + log.Println("ERR: Failed to encode sessions in response:", err) + web.Error(w, "failed to encode sessions", http.StatusInternalServerError) + } +} + +func (h *AdminHandler) RevokeServiceSession(w http.ResponseWriter, r *http.Request) { + sessionId := chi.URLParam(r, "id") + parsed, err := uuid.Parse(sessionId) + if err != nil { + web.Error(w, "provided service id is not valid", http.StatusBadRequest) + return + } + + if err := h.repo.RevokeServiceSession(r.Context(), parsed); err != nil { + log.Println("ERR: Failed to revoke service session:", err) + web.Error(w, "failed to revoke service session", http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) + w.Write([]byte("{\"success\":true}")) +} diff --git a/internal/auth/login.go b/internal/auth/login.go index cab16a6..631a63e 100644 --- a/internal/auth/login.go +++ b/internal/auth/login.go @@ -5,6 +5,7 @@ import ( "log" "net/http" + "gitea.local/admin/hspguard/internal/repository" "gitea.local/admin/hspguard/internal/util" "gitea.local/admin/hspguard/internal/web" ) @@ -32,12 +33,14 @@ func (h *AuthHandler) login(w http.ResponseWriter, r *http.Request) { user, err := h.repo.FindUserEmail(r.Context(), params.Email) if err != nil { - web.Error(w, "user with provided email does not exists", http.StatusBadRequest) + log.Printf("DEBUG: No user found with '%s' email: %v\n", params.Email, err) + web.Error(w, "email or/and password are incorrect", http.StatusBadRequest) return } if !util.VerifyPassword(params.Password, user.PasswordHash) { - web.Error(w, "username or/and password are incorrect", http.StatusBadRequest) + log.Printf("DEBUG: Incorrect password '%s' for '%s' email: %v\n", params.Password, params.Email, err) + web.Error(w, "email or/and password are incorrect", http.StatusBadRequest) return } @@ -47,6 +50,29 @@ func (h *AuthHandler) login(w http.ResponseWriter, r *http.Request) { return } + userAgent := r.UserAgent() + + ipAddr := util.GetClientIP(r) + deviceInfo := util.BuildDeviceInfo(userAgent, ipAddr) + + // Create User Session + session, err := h.repo.CreateUserSession(r.Context(), repository.CreateUserSessionParams{ + UserID: user.ID, + SessionType: "user", + ExpiresAt: &refresh.ExpiresAt, + LastActive: nil, + IpAddress: &ipAddr, + UserAgent: &userAgent, + AccessTokenID: &access.ID, + RefreshTokenID: &refresh.ID, + DeviceInfo: deviceInfo, + }) + if err != nil { + log.Printf("ERR: Failed to create user session after logging in: %v\n", err) + } + + log.Printf("INFO: User session created for '%s' with '%s' id\n", user.Email, session.ID.String()) + if err := h.repo.UpdateLastLogin(r.Context(), user.ID); err != nil { web.Error(w, "failed to update user's last login", http.StatusInternalServerError) return @@ -68,8 +94,8 @@ func (h *AuthHandler) login(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") if err := encoder.Encode(Response{ - AccessToken: access, - RefreshToken: refresh, + AccessToken: access.Token, + RefreshToken: refresh.Token, FullName: user.FullName, Email: user.Email, Id: user.ID.String(), diff --git a/internal/auth/refresh.go b/internal/auth/refresh.go index 22f2949..049042a 100644 --- a/internal/auth/refresh.go +++ b/internal/auth/refresh.go @@ -3,10 +3,12 @@ package auth import ( "encoding/json" "fmt" + "log" "net/http" "strings" "time" + "gitea.local/admin/hspguard/internal/repository" "gitea.local/admin/hspguard/internal/types" "gitea.local/admin/hspguard/internal/util" "gitea.local/admin/hspguard/internal/web" @@ -64,6 +66,44 @@ func (h *AuthHandler) refreshToken(w http.ResponseWriter, r *http.Request) { return } + jti, err := uuid.Parse(userClaims.ID) + if session, err := h.repo.GetUserSessionByRefreshJTI(r.Context(), &jti); err != nil { + log.Printf("WARN: No existing user session found for user with '%s' email (jti: '%s'): %v\n", user.Email, userClaims.ID, err) + + userAgent := r.UserAgent() + + ipAddr := util.GetClientIP(r) + deviceInfo := util.BuildDeviceInfo(userAgent, ipAddr) + + // Create User Session + session, err := h.repo.CreateUserSession(r.Context(), repository.CreateUserSessionParams{ + UserID: user.ID, + SessionType: "user", + ExpiresAt: &refresh.ExpiresAt, + LastActive: nil, + IpAddress: &ipAddr, + UserAgent: &userAgent, + AccessTokenID: &access.ID, + RefreshTokenID: &refresh.ID, + DeviceInfo: deviceInfo, + }) + if err != nil { + log.Printf("ERR: Failed to create user session after logging in: %v\n", err) + } + + log.Printf("INFO: User session created for '%s' with '%s' id\n", user.Email, session.ID.String()) + } else { + err := h.repo.UpdateSessionTokens(r.Context(), repository.UpdateSessionTokensParams{ + ID: session.ID, + AccessTokenID: &access.ID, + RefreshTokenID: &refresh.ID, + ExpiresAt: &refresh.ExpiresAt, + }) + if err != nil { + log.Printf("ERR: Failed to update user session with '%s' id: %v\n", session.ID.String(), err) + } + } + type Response struct { AccessToken string `json:"access"` RefreshToken string `json:"refresh"` @@ -74,8 +114,8 @@ func (h *AuthHandler) refreshToken(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") if err := encoder.Encode(Response{ - AccessToken: access, - RefreshToken: refresh, + AccessToken: access.Token, + RefreshToken: refresh.Token, }); err != nil { web.Error(w, "failed to encode response", http.StatusInternalServerError) } diff --git a/internal/auth/routes.go b/internal/auth/routes.go index 7ac89e9..956cf16 100644 --- a/internal/auth/routes.go +++ b/internal/auth/routes.go @@ -11,6 +11,7 @@ import ( "gitea.local/admin/hspguard/internal/util" "github.com/go-chi/chi/v5" "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" ) type AuthHandler struct { @@ -19,7 +20,10 @@ type AuthHandler struct { cfg *config.AppConfig } -func (h *AuthHandler) signTokens(user *repository.User) (string, string, error) { +func (h *AuthHandler) signTokens(user *repository.User) (*types.SignedToken, *types.SignedToken, error) { + accessExpiresAt := time.Now().Add(15 * time.Minute) + accessJTI := uuid.New() + accessClaims := types.UserClaims{ UserEmail: user.Email, IsAdmin: user.IsAdmin, @@ -27,15 +31,19 @@ func (h *AuthHandler) signTokens(user *repository.User) (string, string, error) Issuer: h.cfg.Uri, Subject: user.ID.String(), IssuedAt: jwt.NewNumericDate(time.Now()), - ExpiresAt: jwt.NewNumericDate(time.Now().Add(15 * time.Minute)), + ExpiresAt: jwt.NewNumericDate(accessExpiresAt), + ID: accessJTI.String(), }, } accessToken, err := util.SignJwtToken(accessClaims, h.cfg.Jwt.PrivateKey) if err != nil { - return "", "", err + return nil, nil, err } + refreshExpiresAt := time.Now().Add(30 * 24 * time.Hour) + refreshJTI := uuid.New() + refreshClaims := types.UserClaims{ UserEmail: user.Email, IsAdmin: user.IsAdmin, @@ -43,16 +51,17 @@ func (h *AuthHandler) signTokens(user *repository.User) (string, string, error) Issuer: h.cfg.Uri, Subject: user.ID.String(), IssuedAt: jwt.NewNumericDate(time.Now()), - ExpiresAt: jwt.NewNumericDate(time.Now().Add(30 * 24 * time.Hour)), + ExpiresAt: jwt.NewNumericDate(refreshExpiresAt), + ID: refreshJTI.String(), }, } refreshToken, err := util.SignJwtToken(refreshClaims, h.cfg.Jwt.PrivateKey) if err != nil { - return "", "", err + return nil, nil, err } - return accessToken, refreshToken, nil + return types.NewSignedToken(accessToken, accessExpiresAt, accessJTI), types.NewSignedToken(refreshToken, refreshExpiresAt, refreshJTI), nil } func NewAuthHandler(repo *repository.Queries, cache *cache.Client, cfg *config.AppConfig) *AuthHandler { @@ -66,13 +75,14 @@ func NewAuthHandler(repo *repository.Queries, cache *cache.Client, cfg *config.A func (h *AuthHandler) RegisterRoutes(api chi.Router) { api.Route("/auth", func(r chi.Router) { r.Group(func(protected chi.Router) { - authMiddleware := imiddleware.NewAuthMiddleware(h.cfg) + authMiddleware := imiddleware.NewAuthMiddleware(h.cfg, h.repo) protected.Use(authMiddleware.Runner) protected.Get("/profile", h.getProfile) protected.Post("/email", h.requestEmailOtp) protected.Post("/email/otp", h.confirmOtp) protected.Post("/verify", h.finishVerification) + protected.Post("/signout", h.signOut) }) r.Post("/login", h.login) diff --git a/internal/auth/signout.go b/internal/auth/signout.go new file mode 100644 index 0000000..6096dae --- /dev/null +++ b/internal/auth/signout.go @@ -0,0 +1,40 @@ +package auth + +import ( + "log" + "net/http" + + "gitea.local/admin/hspguard/internal/util" + "github.com/google/uuid" +) + +func (h *AuthHandler) signOut(w http.ResponseWriter, r *http.Request) { + defer func() { + w.WriteHeader(http.StatusOK) + w.Write([]byte("{\"status\": \"ok\"}")) + }() + + jti, ok := util.GetRequestJTI(r.Context()) + if !ok { + log.Println("WARN: No JTI found in request") + return + } + + jtiId, err := uuid.Parse(jti) + if err != nil { + log.Printf("ERR: Failed to parse jti '%s' as v4 uuid: %v\n", jti, err) + return + } + + session, err := h.repo.GetUserSessionByAccessJTI(r.Context(), &jtiId) + if err != nil { + log.Printf("WARN: Could not find session by jti id '%s': %v\n", jtiId.String(), err) + return + } + + if err := h.repo.RevokeUserSession(r.Context(), session.ID); err != nil { + log.Printf("ERR: Failed to revoke session with '%s' id: %v\n", session.ID.String(), err) + } else { + log.Printf("INFO: Revoked session with jti = '%s' and session id = '%s'\n", jtiId.String(), session.ID.String()) + } +} diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go index e934e04..059e8d2 100644 --- a/internal/middleware/auth.go +++ b/internal/middleware/auth.go @@ -3,22 +3,27 @@ package middleware import ( "context" "fmt" + "log" "net/http" "strings" "gitea.local/admin/hspguard/internal/config" + "gitea.local/admin/hspguard/internal/repository" "gitea.local/admin/hspguard/internal/types" "gitea.local/admin/hspguard/internal/util" "gitea.local/admin/hspguard/internal/web" + "github.com/google/uuid" ) type AuthMiddleware struct { - cfg *config.AppConfig + cfg *config.AppConfig + repo *repository.Queries } -func NewAuthMiddleware(cfg *config.AppConfig) *AuthMiddleware { +func NewAuthMiddleware(cfg *config.AppConfig, repo *repository.Queries) *AuthMiddleware { return &AuthMiddleware{ cfg, + repo, } } @@ -45,7 +50,28 @@ func (m *AuthMiddleware) Runner(next http.Handler) http.Handler { return } + // TODO: redis caching + parsed, err := uuid.Parse(userClaims.ID) + if err != nil { + log.Printf("ERR: Failed to parse token JTI '%s': %v\n", userClaims.ID, err) + web.Error(w, "failed to get session", http.StatusUnauthorized) + return + } + session, err := m.repo.GetUserSessionByAccessJTI(r.Context(), &parsed) + if err != nil { + log.Printf("ERR: Failed to find session with '%s' JTI: %v\n", parsed.String(), err) + web.Error(w, "no session found", http.StatusUnauthorized) + return + } + + if !session.IsActive { + log.Printf("INFO: Inactive session trying to authorize: %s\n", session.AccessTokenID) + web.Error(w, "no session found", http.StatusUnauthorized) + return + } + ctx := context.WithValue(r.Context(), types.UserIdKey, userClaims.Subject) + ctx = context.WithValue(ctx, types.JTIKey, userClaims.ID) next.ServeHTTP(w, r.WithContext(ctx)) }) } diff --git a/internal/oauth/authorize.go b/internal/oauth/authorize.go index a2fe10e..38bce3f 100644 --- a/internal/oauth/authorize.go +++ b/internal/oauth/authorize.go @@ -3,7 +3,6 @@ package oauth import ( "fmt" "net/http" - "slices" "strings" "gitea.local/admin/hspguard/internal/web" @@ -29,43 +28,14 @@ func (h *OAuthHandler) AuthorizeClient(w http.ResponseWriter, r *http.Request) { return } - client, err := h.repo.GetApiServiceCID(r.Context(), clientId) - if err != nil { - uri := fmt.Sprintf("%s?error=access_denied&error_description=Service+not+authorized", redirectUri) - if state != "" { - uri += "&state=" + state - } - http.Redirect(w, r, uri, http.StatusFound) - return - } + scopes := strings.Split(strings.TrimSpace(r.URL.Query().Get("scope")), " ") - if !client.IsActive { - uri := fmt.Sprintf("%s?error=temporarily_unavailable&error_description=Service+not+active", redirectUri) - if state != "" { - uri += "&state=" + state - } - http.Redirect(w, r, uri, http.StatusFound) - return - } - - scopes := strings.SplitSeq(strings.TrimSpace(r.URL.Query().Get("scope")), " ") - - for scope := range scopes { - if !slices.Contains(client.Scopes, scope) { - uri := fmt.Sprintf("%s?error=invalid_scope&error_description=Scope+%s+is+not+allowed", redirectUri, strings.ReplaceAll(scope, " ", "+")) - if state != "" { - uri += "&state=" + state - } - http.Redirect(w, r, uri, http.StatusFound) - return - } - } - - if !slices.Contains(client.RedirectUris, redirectUri) { - uri := fmt.Sprintf("%s?error=invalid_request&error_description=Redirect+URI+is+not+allowed", redirectUri) - if state != "" { - uri += "&state=" + state - } + if uri, err := h.verifyOAuthClient(r.Context(), &VerifyOAuthClientParams{ + ClientID: clientId, + RedirectURI: &redirectUri, + State: state, + Scopes: &scopes, + }); err != nil { http.Redirect(w, r, uri, http.StatusFound) return } diff --git a/internal/oauth/client.go b/internal/oauth/client.go new file mode 100644 index 0000000..14c660e --- /dev/null +++ b/internal/oauth/client.go @@ -0,0 +1,58 @@ +package oauth + +import ( + "context" + "fmt" + "slices" + "strings" +) + +type VerifyOAuthClientParams struct { + ClientID string `json:"client_id"` + RedirectURI *string `json:"redirect_uri"` + State string `json:"state"` + Scopes *[]string `json:"scopes"` +} + +func (h *OAuthHandler) verifyOAuthClient(ctx context.Context, params *VerifyOAuthClientParams) (string, error) { + client, err := h.repo.GetApiServiceCID(ctx, params.ClientID) + if err != nil { + uri := fmt.Sprintf("%s?error=access_denied&error_description=Service+not+authorized", *params.RedirectURI) + if params.State != "" { + uri += "&state=" + params.State + } + return uri, fmt.Errorf("target oauth service with client id '%s' is not registered", params.ClientID) + } + + if !client.IsActive { + uri := fmt.Sprintf("%s?error=temporarily_unavailable&error_description=Service+not+active", *params.RedirectURI) + if params.State != "" { + uri += "&state=" + params.State + } + return uri, fmt.Errorf("target oauth service with client id '%s' is not available", client.ClientID) + } + + if params.Scopes != nil { + for _, scope := range *params.Scopes { + if !slices.Contains(client.Scopes, scope) { + uri := fmt.Sprintf("%s?error=invalid_scope&error_description=Scope+%s+is+not+allowed", *params.RedirectURI, strings.ReplaceAll(scope, " ", "+")) + if params.State != "" { + uri += "&state=" + params.State + } + return uri, fmt.Errorf("unallowed scope '%s' requested", scope) + } + } + } + + if params.RedirectURI != nil { + if !slices.Contains(client.RedirectUris, *params.RedirectURI) { + uri := fmt.Sprintf("%s?error=invalid_request&error_description=Redirect+URI+is+not+allowed", *params.RedirectURI) + if params.State != "" { + uri += "&state=" + params.State + } + return uri, fmt.Errorf("redirect uri '%s' is unallowed", *params.RedirectURI) + } + } + + return "", nil +} diff --git a/internal/oauth/code.go b/internal/oauth/code.go index 7645e62..f175bbc 100644 --- a/internal/oauth/code.go +++ b/internal/oauth/code.go @@ -39,6 +39,16 @@ func (h *OAuthHandler) getAuthCode(w http.ResponseWriter, r *http.Request) { return } + if _, err := h.verifyOAuthClient(r.Context(), &VerifyOAuthClientParams{ + ClientID: req.ClientID, + RedirectURI: nil, + State: "", + Scopes: nil, + }); err != nil { + web.Error(w, err.Error(), http.StatusInternalServerError) + return + } + buf := make([]byte, 32) _, err = rand.Read(buf) if err != nil { diff --git a/internal/oauth/routes.go b/internal/oauth/routes.go index 10a6876..66331cd 100644 --- a/internal/oauth/routes.go +++ b/internal/oauth/routes.go @@ -25,7 +25,7 @@ func NewOAuthHandler(repo *repository.Queries, cache *cache.Client, cfg *config. func (h *OAuthHandler) RegisterRoutes(router chi.Router) { router.Route("/oauth", func(r chi.Router) { r.Group(func(protected chi.Router) { - authMiddleware := imiddleware.NewAuthMiddleware(h.cfg) + authMiddleware := imiddleware.NewAuthMiddleware(h.cfg, h.repo) protected.Use(authMiddleware.Runner) protected.Post("/code", h.getAuthCode) diff --git a/internal/oauth/token.go b/internal/oauth/token.go index 215126b..7613f34 100644 --- a/internal/oauth/token.go +++ b/internal/oauth/token.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "log" + "math" "net/http" "strings" "time" @@ -17,20 +18,10 @@ import ( "github.com/google/uuid" ) -type ApiToken struct { - Token string - Expiration float64 -} - -type ApiTokens struct { - ID ApiToken - Access ApiToken - Refresh ApiToken -} - -func (h *OAuthHandler) signApiTokens(user *repository.User, apiService *repository.ApiService, nonce *string) (*ApiTokens, error) { +func (h *OAuthHandler) signApiTokens(user *repository.User, apiService *repository.ApiService, nonce *string) (*types.SignedToken, *types.SignedToken, *types.SignedToken, error) { accessExpiresIn := 15 * time.Minute accessExpiresAt := time.Now().Add(accessExpiresIn) + accessJTI := uuid.New() accessClaims := types.ApiClaims{ Permissions: []string{}, @@ -40,12 +31,13 @@ func (h *OAuthHandler) signApiTokens(user *repository.User, apiService *reposito Audience: jwt.ClaimStrings{apiService.ClientID}, IssuedAt: jwt.NewNumericDate(time.Now()), ExpiresAt: jwt.NewNumericDate(accessExpiresAt), + ID: accessJTI.String(), }, } access, err := util.SignJwtToken(accessClaims, h.cfg.Jwt.PrivateKey) if err != nil { - return nil, err + return nil, nil, nil, err } var roles = []string{"user"} @@ -56,6 +48,7 @@ func (h *OAuthHandler) signApiTokens(user *repository.User, apiService *reposito idExpiresIn := 15 * time.Minute idExpiresAt := time.Now().Add(idExpiresIn) + idJTI := uuid.New() idClaims := types.IdTokenClaims{ Email: user.Email, @@ -70,16 +63,18 @@ func (h *OAuthHandler) signApiTokens(user *repository.User, apiService *reposito Audience: jwt.ClaimStrings{apiService.ClientID}, IssuedAt: jwt.NewNumericDate(time.Now()), ExpiresAt: jwt.NewNumericDate(idExpiresAt), + ID: idJTI.String(), }, } idToken, err := util.SignJwtToken(idClaims, h.cfg.Jwt.PrivateKey) if err != nil { - return nil, err + return nil, nil, nil, err } refreshExpiresIn := 24 * time.Hour refreshExpiresAt := time.Now().Add(refreshExpiresIn) + refreshJTI := uuid.New() refreshClaims := types.ApiRefreshClaims{ UserID: user.ID.String(), @@ -89,28 +84,16 @@ func (h *OAuthHandler) signApiTokens(user *repository.User, apiService *reposito Audience: jwt.ClaimStrings{apiService.ClientID}, IssuedAt: jwt.NewNumericDate(time.Now()), ExpiresAt: jwt.NewNumericDate(refreshExpiresAt), + ID: refreshJTI.String(), }, } refresh, err := util.SignJwtToken(refreshClaims, h.cfg.Jwt.PrivateKey) if err != nil { - return nil, err + return nil, nil, nil, err } - return &ApiTokens{ - ID: ApiToken{ - Token: idToken, - Expiration: idExpiresIn.Seconds(), - }, - Access: ApiToken{ - Token: access, - Expiration: accessExpiresIn.Seconds(), - }, - Refresh: ApiToken{ - Token: refresh, - Expiration: refreshExpiresIn.Seconds(), - }, - }, nil + return types.NewSignedToken(idToken, idExpiresAt, idJTI), types.NewSignedToken(access, accessExpiresAt, accessJTI), types.NewSignedToken(refresh, refreshExpiresAt, refreshJTI), nil } func (h *OAuthHandler) tokenEndpoint(w http.ResponseWriter, r *http.Request) { @@ -152,50 +135,93 @@ func (h *OAuthHandler) tokenEndpoint(w http.ResponseWriter, r *http.Request) { } grantType := r.FormValue("grant_type") - redirectUri := r.FormValue("redirect_uri") - log.Printf("Redirect URI is %s\n", redirectUri) + log.Println("DEBUG: Verifying target oauth client before proceeding...") + + if _, err := h.verifyOAuthClient(r.Context(), &VerifyOAuthClientParams{ + ClientID: clientId, + RedirectURI: nil, + State: "", + Scopes: nil, + }); err != nil { + web.Error(w, err.Error(), http.StatusInternalServerError) + return + } switch grantType { case "authorization_code": + redirectUri := r.FormValue("redirect_uri") + log.Printf("Redirect URI is %s\n", redirectUri) + code := r.FormValue("code") fmt.Printf("Code received: %s\n", code) - session, err := h.cache.GetAuthCode(r.Context(), code) + codeSession, err := h.cache.GetAuthCode(r.Context(), code) if err != nil { log.Printf("ERR: Failed to find session under the code %s: %v\n", code, err) web.Error(w, "no session found under this auth code", http.StatusNotFound) return } - log.Printf("DEBUG: Fetched code session: %#v\n", session) + log.Printf("DEBUG: Fetched code session: %#v\n", codeSession) - apiService, err := h.repo.GetApiServiceCID(r.Context(), session.ClientID) + apiService, err := h.repo.GetApiServiceCID(r.Context(), codeSession.ClientID) if err != nil { - log.Printf("ERR: Could not find API service with client %s: %v\n", session.ClientID, err) + log.Printf("ERR: Could not find API service with client %s: %v\n", codeSession.ClientID, err) web.Error(w, "service is not registered", http.StatusForbidden) return } - if session.ClientID != clientId { + if codeSession.ClientID != clientId { web.Error(w, "invalid auth", http.StatusUnauthorized) return } - user, err := h.repo.FindUserId(r.Context(), uuid.MustParse(session.UserID)) + user, err := h.repo.FindUserId(r.Context(), uuid.MustParse(codeSession.UserID)) if err != nil { web.Error(w, "requested user not found", http.StatusNotFound) return } - tokens, err := h.signApiTokens(&user, &apiService, &session.Nonce) + id, access, refresh, err := h.signApiTokens(&user, &apiService, &codeSession.Nonce) if err != nil { log.Println("ERR: Failed to sign api tokens:", err) web.Error(w, "failed to sign tokens", http.StatusInternalServerError) return } + log.Printf("DEBUG: Created api tokens: %v\n\n%v\n\n%v\n", id.ID.String(), access.ID.String(), refresh.ID.String()) + + userId, err := uuid.Parse(codeSession.UserID) + if err != nil { + log.Printf("ERR: Failed to parse user '%s' uuid: %v\n", codeSession.UserID, err) + web.Error(w, "failed to sign tokens", http.StatusInternalServerError) + return + } + + ipAddr := util.GetClientIP(r) + ua := r.UserAgent() + + session, err := h.repo.CreateServiceSession(r.Context(), repository.CreateServiceSessionParams{ + ServiceID: apiService.ID, + ClientID: apiService.ClientID, + UserID: &userId, + ExpiresAt: &refresh.ExpiresAt, + LastActive: nil, + IpAddress: &ipAddr, + UserAgent: &ua, + AccessTokenID: &access.ID, + RefreshTokenID: &refresh.ID, + }) + if err != nil { + log.Printf("ERR: Failed to create new service session: %v\n", err) + web.Error(w, "failed to create session", http.StatusInternalServerError) + return + } + + log.Printf("INFO: Service session created for '%s' client_id with '%s' id\n", apiService.ClientID, session.ID.String()) + type Response struct { IdToken string `json:"id_token"` TokenType string `json:"token_type"` @@ -207,11 +233,11 @@ func (h *OAuthHandler) tokenEndpoint(w http.ResponseWriter, r *http.Request) { } response := Response{ - IdToken: tokens.ID.Token, + IdToken: id.Token, TokenType: "Bearer", - AccessToken: tokens.Access.Token, - RefreshToken: tokens.Refresh.Token, - ExpiresIn: tokens.Access.Expiration, + AccessToken: access.Token, + RefreshToken: refresh.Token, + ExpiresIn: math.Ceil(access.ExpiresAt.Sub(time.Now()).Seconds()), Email: user.Email, } @@ -244,6 +270,26 @@ func (h *OAuthHandler) tokenEndpoint(w http.ResponseWriter, r *http.Request) { return } + refreshJTI, err := uuid.Parse(claims.ID) + if err != nil { + log.Printf("ERR: Failed to parse refresh token JTI as uuid: %v\n", err) + web.Error(w, "failed to refresh token", http.StatusInternalServerError) + return + } + + session, err := h.repo.GetServiceSessionByRefreshJTI(r.Context(), &refreshJTI) + if err != nil { + log.Printf("ERR: Failed to find session by '%s' refresh jti: %v\n", refreshJTI.String(), err) + web.Error(w, "session invalid", http.StatusUnauthorized) + return + } + + if !session.IsActive { + log.Printf("INFO: Session with id '%s' is not active", session.ID.String()) + web.Error(w, "session ended", http.StatusUnauthorized) + return + } + userID, err := uuid.Parse(claims.UserID) if err != nil { web.Error(w, "invalid user credentials in refresh token", http.StatusBadRequest) @@ -257,7 +303,18 @@ func (h *OAuthHandler) tokenEndpoint(w http.ResponseWriter, r *http.Request) { return } - tokens, err := h.signApiTokens(&user, &apiService, nil) + id, access, refresh, err := h.signApiTokens(&user, &apiService, nil) + + if err := h.repo.UpdateServiceSessionTokens(r.Context(), repository.UpdateServiceSessionTokensParams{ + ID: session.ID, + AccessTokenID: &access.ID, + RefreshTokenID: &refresh.ID, + ExpiresAt: &refresh.ExpiresAt, + }); err != nil { + log.Printf("ERR: Failed to update service session with '%s' id: %v\n", session.ID.String(), err) + web.Error(w, "failed to update session", http.StatusInternalServerError) + return + } type Response struct { IdToken string `json:"id_token"` @@ -268,11 +325,11 @@ func (h *OAuthHandler) tokenEndpoint(w http.ResponseWriter, r *http.Request) { } response := Response{ - IdToken: tokens.ID.Token, + IdToken: id.Token, TokenType: "Bearer", - AccessToken: tokens.Access.Token, - RefreshToken: tokens.Refresh.Token, - ExpiresIn: tokens.Access.Expiration, + AccessToken: access.Token, + RefreshToken: refresh.Token, + ExpiresIn: math.Ceil(access.ExpiresAt.Sub(time.Now()).Seconds()), } log.Printf("DEBUG: refresh - sending following response: %#v\n", response) diff --git a/internal/repository/models.go b/internal/repository/models.go index 8ebd95f..ee9dfd6 100644 --- a/internal/repository/models.go +++ b/internal/repository/models.go @@ -25,6 +25,24 @@ type ApiService struct { IconUrl *string `json:"icon_url"` } +type ServiceSession struct { + ID uuid.UUID `json:"id"` + ServiceID uuid.UUID `json:"service_id"` + ClientID string `json:"client_id"` + UserID *uuid.UUID `json:"user_id"` + IssuedAt time.Time `json:"issued_at"` + ExpiresAt *time.Time `json:"expires_at"` + LastActive *time.Time `json:"last_active"` + IpAddress *string `json:"ip_address"` + UserAgent *string `json:"user_agent"` + AccessTokenID *uuid.UUID `json:"access_token_id"` + RefreshTokenID *uuid.UUID `json:"refresh_token_id"` + IsActive bool `json:"is_active"` + RevokedAt *time.Time `json:"revoked_at"` + Scope *string `json:"scope"` + Claims []byte `json:"claims"` +} + type User struct { ID uuid.UUID `json:"id"` Email string `json:"email"` @@ -41,3 +59,19 @@ type User struct { AvatarVerified bool `json:"avatar_verified"` Verified bool `json:"verified"` } + +type UserSession struct { + ID uuid.UUID `json:"id"` + UserID uuid.UUID `json:"user_id"` + SessionType string `json:"session_type"` + IssuedAt time.Time `json:"issued_at"` + ExpiresAt *time.Time `json:"expires_at"` + LastActive *time.Time `json:"last_active"` + IpAddress *string `json:"ip_address"` + UserAgent *string `json:"user_agent"` + AccessTokenID *uuid.UUID `json:"access_token_id"` + RefreshTokenID *uuid.UUID `json:"refresh_token_id"` + DeviceInfo []byte `json:"device_info"` + IsActive bool `json:"is_active"` + RevokedAt *time.Time `json:"revoked_at"` +} diff --git a/internal/repository/service_sessions.sql.go b/internal/repository/service_sessions.sql.go new file mode 100644 index 0000000..5e1dc23 --- /dev/null +++ b/internal/repository/service_sessions.sql.go @@ -0,0 +1,419 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: service_sessions.sql + +package repository + +import ( + "context" + "time" + + "github.com/google/uuid" +) + +const createServiceSession = `-- name: CreateServiceSession :one +INSERT INTO service_sessions ( + service_id, client_id, user_id, issued_at, expires_at, last_active, + ip_address, user_agent, access_token_id, refresh_token_id, + is_active, scope, claims +) VALUES ( + $1, $2, $3, NOW(), $4, $5, + $6, $7, $8, $9, + TRUE, $10, $11 +) +RETURNING id, service_id, client_id, user_id, issued_at, expires_at, last_active, ip_address, user_agent, access_token_id, refresh_token_id, is_active, revoked_at, scope, claims +` + +type CreateServiceSessionParams struct { + ServiceID uuid.UUID `json:"service_id"` + ClientID string `json:"client_id"` + UserID *uuid.UUID `json:"user_id"` + ExpiresAt *time.Time `json:"expires_at"` + LastActive *time.Time `json:"last_active"` + IpAddress *string `json:"ip_address"` + UserAgent *string `json:"user_agent"` + AccessTokenID *uuid.UUID `json:"access_token_id"` + RefreshTokenID *uuid.UUID `json:"refresh_token_id"` + Scope *string `json:"scope"` + Claims []byte `json:"claims"` +} + +func (q *Queries) CreateServiceSession(ctx context.Context, arg CreateServiceSessionParams) (ServiceSession, error) { + row := q.db.QueryRow(ctx, createServiceSession, + arg.ServiceID, + arg.ClientID, + arg.UserID, + arg.ExpiresAt, + arg.LastActive, + arg.IpAddress, + arg.UserAgent, + arg.AccessTokenID, + arg.RefreshTokenID, + arg.Scope, + arg.Claims, + ) + var i ServiceSession + err := row.Scan( + &i.ID, + &i.ServiceID, + &i.ClientID, + &i.UserID, + &i.IssuedAt, + &i.ExpiresAt, + &i.LastActive, + &i.IpAddress, + &i.UserAgent, + &i.AccessTokenID, + &i.RefreshTokenID, + &i.IsActive, + &i.RevokedAt, + &i.Scope, + &i.Claims, + ) + return i, err +} + +const getServiceSessionByAccessJTI = `-- name: GetServiceSessionByAccessJTI :one +SELECT id, service_id, client_id, user_id, issued_at, expires_at, last_active, ip_address, user_agent, access_token_id, refresh_token_id, is_active, revoked_at, scope, claims FROM service_sessions +WHERE access_token_id = $1 + AND is_active = TRUE +` + +func (q *Queries) GetServiceSessionByAccessJTI(ctx context.Context, accessTokenID *uuid.UUID) (ServiceSession, error) { + row := q.db.QueryRow(ctx, getServiceSessionByAccessJTI, accessTokenID) + var i ServiceSession + err := row.Scan( + &i.ID, + &i.ServiceID, + &i.ClientID, + &i.UserID, + &i.IssuedAt, + &i.ExpiresAt, + &i.LastActive, + &i.IpAddress, + &i.UserAgent, + &i.AccessTokenID, + &i.RefreshTokenID, + &i.IsActive, + &i.RevokedAt, + &i.Scope, + &i.Claims, + ) + return i, err +} + +const getServiceSessionByRefreshJTI = `-- name: GetServiceSessionByRefreshJTI :one +SELECT id, service_id, client_id, user_id, issued_at, expires_at, last_active, ip_address, user_agent, access_token_id, refresh_token_id, is_active, revoked_at, scope, claims FROM service_sessions +WHERE refresh_token_id = $1 +` + +func (q *Queries) GetServiceSessionByRefreshJTI(ctx context.Context, refreshTokenID *uuid.UUID) (ServiceSession, error) { + row := q.db.QueryRow(ctx, getServiceSessionByRefreshJTI, refreshTokenID) + var i ServiceSession + err := row.Scan( + &i.ID, + &i.ServiceID, + &i.ClientID, + &i.UserID, + &i.IssuedAt, + &i.ExpiresAt, + &i.LastActive, + &i.IpAddress, + &i.UserAgent, + &i.AccessTokenID, + &i.RefreshTokenID, + &i.IsActive, + &i.RevokedAt, + &i.Scope, + &i.Claims, + ) + return i, err +} + +const getServiceSessions = `-- name: GetServiceSessions :many +SELECT session.id, session.service_id, session.client_id, session.user_id, session.issued_at, session.expires_at, session.last_active, session.ip_address, session.user_agent, session.access_token_id, session.refresh_token_id, session.is_active, session.revoked_at, session.scope, session.claims, service.id, service.client_id, service.client_secret, service.name, service.redirect_uris, service.scopes, service.grant_types, service.created_at, service.updated_at, service.is_active, service.description, service.icon_url, u.id, u.email, u.full_name, u.password_hash, u.is_admin, u.created_at, u.updated_at, u.last_login, u.phone_number, u.profile_picture, u.created_by, u.email_verified, u.avatar_verified, u.verified +FROM service_sessions AS session +JOIN api_services AS service ON service.id = session.service_id +JOIN users AS u ON u.id = session.user_id +ORDER BY session.issued_at DESC +LIMIT $1 OFFSET $2 +` + +type GetServiceSessionsParams struct { + Limit int32 `json:"limit"` + Offset int32 `json:"offset"` +} + +type GetServiceSessionsRow struct { + ServiceSession ServiceSession `json:"service_session"` + ApiService ApiService `json:"api_service"` + User User `json:"user"` +} + +func (q *Queries) GetServiceSessions(ctx context.Context, arg GetServiceSessionsParams) ([]GetServiceSessionsRow, error) { + rows, err := q.db.Query(ctx, getServiceSessions, arg.Limit, arg.Offset) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetServiceSessionsRow + for rows.Next() { + var i GetServiceSessionsRow + if err := rows.Scan( + &i.ServiceSession.ID, + &i.ServiceSession.ServiceID, + &i.ServiceSession.ClientID, + &i.ServiceSession.UserID, + &i.ServiceSession.IssuedAt, + &i.ServiceSession.ExpiresAt, + &i.ServiceSession.LastActive, + &i.ServiceSession.IpAddress, + &i.ServiceSession.UserAgent, + &i.ServiceSession.AccessTokenID, + &i.ServiceSession.RefreshTokenID, + &i.ServiceSession.IsActive, + &i.ServiceSession.RevokedAt, + &i.ServiceSession.Scope, + &i.ServiceSession.Claims, + &i.ApiService.ID, + &i.ApiService.ClientID, + &i.ApiService.ClientSecret, + &i.ApiService.Name, + &i.ApiService.RedirectUris, + &i.ApiService.Scopes, + &i.ApiService.GrantTypes, + &i.ApiService.CreatedAt, + &i.ApiService.UpdatedAt, + &i.ApiService.IsActive, + &i.ApiService.Description, + &i.ApiService.IconUrl, + &i.User.ID, + &i.User.Email, + &i.User.FullName, + &i.User.PasswordHash, + &i.User.IsAdmin, + &i.User.CreatedAt, + &i.User.UpdatedAt, + &i.User.LastLogin, + &i.User.PhoneNumber, + &i.User.ProfilePicture, + &i.User.CreatedBy, + &i.User.EmailVerified, + &i.User.AvatarVerified, + &i.User.Verified, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getServiceSessionsCount = `-- name: GetServiceSessionsCount :one +SELECT COUNT(*) FROM service_sessions +` + +func (q *Queries) GetServiceSessionsCount(ctx context.Context) (int64, error) { + row := q.db.QueryRow(ctx, getServiceSessionsCount) + var count int64 + err := row.Scan(&count) + return count, err +} + +const listActiveServiceSessionsByClient = `-- name: ListActiveServiceSessionsByClient :many +SELECT id, service_id, client_id, user_id, issued_at, expires_at, last_active, ip_address, user_agent, access_token_id, refresh_token_id, is_active, revoked_at, scope, claims FROM service_sessions +WHERE client_id = $1 + AND is_active = TRUE +ORDER BY issued_at DESC +LIMIT $1 OFFSET $2 +` + +type ListActiveServiceSessionsByClientParams struct { + Limit int32 `json:"limit"` + Offset int32 `json:"offset"` +} + +func (q *Queries) ListActiveServiceSessionsByClient(ctx context.Context, arg ListActiveServiceSessionsByClientParams) ([]ServiceSession, error) { + rows, err := q.db.Query(ctx, listActiveServiceSessionsByClient, arg.Limit, arg.Offset) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ServiceSession + for rows.Next() { + var i ServiceSession + if err := rows.Scan( + &i.ID, + &i.ServiceID, + &i.ClientID, + &i.UserID, + &i.IssuedAt, + &i.ExpiresAt, + &i.LastActive, + &i.IpAddress, + &i.UserAgent, + &i.AccessTokenID, + &i.RefreshTokenID, + &i.IsActive, + &i.RevokedAt, + &i.Scope, + &i.Claims, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listActiveServiceSessionsByUser = `-- name: ListActiveServiceSessionsByUser :many +SELECT id, service_id, client_id, user_id, issued_at, expires_at, last_active, ip_address, user_agent, access_token_id, refresh_token_id, is_active, revoked_at, scope, claims FROM service_sessions +WHERE user_id = $1 + AND is_active = TRUE +ORDER BY issued_at DESC +LIMIT $1 OFFSET $2 +` + +type ListActiveServiceSessionsByUserParams struct { + Limit int32 `json:"limit"` + Offset int32 `json:"offset"` +} + +func (q *Queries) ListActiveServiceSessionsByUser(ctx context.Context, arg ListActiveServiceSessionsByUserParams) ([]ServiceSession, error) { + rows, err := q.db.Query(ctx, listActiveServiceSessionsByUser, arg.Limit, arg.Offset) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ServiceSession + for rows.Next() { + var i ServiceSession + if err := rows.Scan( + &i.ID, + &i.ServiceID, + &i.ClientID, + &i.UserID, + &i.IssuedAt, + &i.ExpiresAt, + &i.LastActive, + &i.IpAddress, + &i.UserAgent, + &i.AccessTokenID, + &i.RefreshTokenID, + &i.IsActive, + &i.RevokedAt, + &i.Scope, + &i.Claims, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listAllServiceSessions = `-- name: ListAllServiceSessions :many +SELECT id, service_id, client_id, user_id, issued_at, expires_at, last_active, ip_address, user_agent, access_token_id, refresh_token_id, is_active, revoked_at, scope, claims FROM service_sessions +ORDER BY issued_at DESC +LIMIT $1 OFFSET $2 +` + +type ListAllServiceSessionsParams struct { + Limit int32 `json:"limit"` + Offset int32 `json:"offset"` +} + +func (q *Queries) ListAllServiceSessions(ctx context.Context, arg ListAllServiceSessionsParams) ([]ServiceSession, error) { + rows, err := q.db.Query(ctx, listAllServiceSessions, arg.Limit, arg.Offset) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ServiceSession + for rows.Next() { + var i ServiceSession + if err := rows.Scan( + &i.ID, + &i.ServiceID, + &i.ClientID, + &i.UserID, + &i.IssuedAt, + &i.ExpiresAt, + &i.LastActive, + &i.IpAddress, + &i.UserAgent, + &i.AccessTokenID, + &i.RefreshTokenID, + &i.IsActive, + &i.RevokedAt, + &i.Scope, + &i.Claims, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const revokeServiceSession = `-- name: RevokeServiceSession :exec +UPDATE service_sessions +SET is_active = FALSE, + revoked_at = NOW() +WHERE id = $1 + AND is_active = TRUE +` + +func (q *Queries) RevokeServiceSession(ctx context.Context, id uuid.UUID) error { + _, err := q.db.Exec(ctx, revokeServiceSession, id) + return err +} + +const updateServiceSessionLastActive = `-- name: UpdateServiceSessionLastActive :exec +UPDATE service_sessions +SET last_active = NOW() +WHERE id = $1 + AND is_active = TRUE +` + +func (q *Queries) UpdateServiceSessionLastActive(ctx context.Context, id uuid.UUID) error { + _, err := q.db.Exec(ctx, updateServiceSessionLastActive, id) + return err +} + +const updateServiceSessionTokens = `-- name: UpdateServiceSessionTokens :exec +UPDATE service_sessions +SET access_token_id = $2, refresh_token_id = $3, expires_at = $4 +WHERE id = $1 + AND is_active = TRUE +` + +type UpdateServiceSessionTokensParams struct { + ID uuid.UUID `json:"id"` + AccessTokenID *uuid.UUID `json:"access_token_id"` + RefreshTokenID *uuid.UUID `json:"refresh_token_id"` + ExpiresAt *time.Time `json:"expires_at"` +} + +func (q *Queries) UpdateServiceSessionTokens(ctx context.Context, arg UpdateServiceSessionTokensParams) error { + _, err := q.db.Exec(ctx, updateServiceSessionTokens, + arg.ID, + arg.AccessTokenID, + arg.RefreshTokenID, + arg.ExpiresAt, + ) + return err +} diff --git a/internal/repository/user_sessions.sql.go b/internal/repository/user_sessions.sql.go new file mode 100644 index 0000000..c520a87 --- /dev/null +++ b/internal/repository/user_sessions.sql.go @@ -0,0 +1,334 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: user_sessions.sql + +package repository + +import ( + "context" + "time" + + "github.com/google/uuid" +) + +const createUserSession = `-- name: CreateUserSession :one +INSERT INTO user_sessions ( + user_id, session_type, issued_at, expires_at, last_active, + ip_address, user_agent, access_token_id, refresh_token_id, + device_info, is_active +) VALUES ( + $1, $2, NOW(), $3, $4, + $5, $6, $7, $8, + $9, TRUE +) +RETURNING id, user_id, session_type, issued_at, expires_at, last_active, ip_address, user_agent, access_token_id, refresh_token_id, device_info, is_active, revoked_at +` + +type CreateUserSessionParams struct { + UserID uuid.UUID `json:"user_id"` + SessionType string `json:"session_type"` + ExpiresAt *time.Time `json:"expires_at"` + LastActive *time.Time `json:"last_active"` + IpAddress *string `json:"ip_address"` + UserAgent *string `json:"user_agent"` + AccessTokenID *uuid.UUID `json:"access_token_id"` + RefreshTokenID *uuid.UUID `json:"refresh_token_id"` + DeviceInfo []byte `json:"device_info"` +} + +func (q *Queries) CreateUserSession(ctx context.Context, arg CreateUserSessionParams) (UserSession, error) { + row := q.db.QueryRow(ctx, createUserSession, + arg.UserID, + arg.SessionType, + arg.ExpiresAt, + arg.LastActive, + arg.IpAddress, + arg.UserAgent, + arg.AccessTokenID, + arg.RefreshTokenID, + arg.DeviceInfo, + ) + var i UserSession + err := row.Scan( + &i.ID, + &i.UserID, + &i.SessionType, + &i.IssuedAt, + &i.ExpiresAt, + &i.LastActive, + &i.IpAddress, + &i.UserAgent, + &i.AccessTokenID, + &i.RefreshTokenID, + &i.DeviceInfo, + &i.IsActive, + &i.RevokedAt, + ) + return i, err +} + +const getUserSessionByAccessJTI = `-- name: GetUserSessionByAccessJTI :one +SELECT id, user_id, session_type, issued_at, expires_at, last_active, ip_address, user_agent, access_token_id, refresh_token_id, device_info, is_active, revoked_at FROM user_sessions +WHERE access_token_id = $1 + AND is_active = TRUE +` + +func (q *Queries) GetUserSessionByAccessJTI(ctx context.Context, accessTokenID *uuid.UUID) (UserSession, error) { + row := q.db.QueryRow(ctx, getUserSessionByAccessJTI, accessTokenID) + var i UserSession + err := row.Scan( + &i.ID, + &i.UserID, + &i.SessionType, + &i.IssuedAt, + &i.ExpiresAt, + &i.LastActive, + &i.IpAddress, + &i.UserAgent, + &i.AccessTokenID, + &i.RefreshTokenID, + &i.DeviceInfo, + &i.IsActive, + &i.RevokedAt, + ) + return i, err +} + +const getUserSessionByRefreshJTI = `-- name: GetUserSessionByRefreshJTI :one +SELECT id, user_id, session_type, issued_at, expires_at, last_active, ip_address, user_agent, access_token_id, refresh_token_id, device_info, is_active, revoked_at FROM user_sessions +WHERE refresh_token_id = $1 +` + +func (q *Queries) GetUserSessionByRefreshJTI(ctx context.Context, refreshTokenID *uuid.UUID) (UserSession, error) { + row := q.db.QueryRow(ctx, getUserSessionByRefreshJTI, refreshTokenID) + var i UserSession + err := row.Scan( + &i.ID, + &i.UserID, + &i.SessionType, + &i.IssuedAt, + &i.ExpiresAt, + &i.LastActive, + &i.IpAddress, + &i.UserAgent, + &i.AccessTokenID, + &i.RefreshTokenID, + &i.DeviceInfo, + &i.IsActive, + &i.RevokedAt, + ) + return i, err +} + +const getUserSessions = `-- name: GetUserSessions :many +SELECT session.id, session.user_id, session.session_type, session.issued_at, session.expires_at, session.last_active, session.ip_address, session.user_agent, session.access_token_id, session.refresh_token_id, session.device_info, session.is_active, session.revoked_at, u.id, u.email, u.full_name, u.password_hash, u.is_admin, u.created_at, u.updated_at, u.last_login, u.phone_number, u.profile_picture, u.created_by, u.email_verified, u.avatar_verified, u.verified +FROM user_sessions AS session +JOIN users AS u ON u.id = session.user_id +ORDER BY session.issued_at DESC +LIMIT $1 OFFSET $2 +` + +type GetUserSessionsParams struct { + Limit int32 `json:"limit"` + Offset int32 `json:"offset"` +} + +type GetUserSessionsRow struct { + UserSession UserSession `json:"user_session"` + User User `json:"user"` +} + +func (q *Queries) GetUserSessions(ctx context.Context, arg GetUserSessionsParams) ([]GetUserSessionsRow, error) { + rows, err := q.db.Query(ctx, getUserSessions, arg.Limit, arg.Offset) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetUserSessionsRow + for rows.Next() { + var i GetUserSessionsRow + if err := rows.Scan( + &i.UserSession.ID, + &i.UserSession.UserID, + &i.UserSession.SessionType, + &i.UserSession.IssuedAt, + &i.UserSession.ExpiresAt, + &i.UserSession.LastActive, + &i.UserSession.IpAddress, + &i.UserSession.UserAgent, + &i.UserSession.AccessTokenID, + &i.UserSession.RefreshTokenID, + &i.UserSession.DeviceInfo, + &i.UserSession.IsActive, + &i.UserSession.RevokedAt, + &i.User.ID, + &i.User.Email, + &i.User.FullName, + &i.User.PasswordHash, + &i.User.IsAdmin, + &i.User.CreatedAt, + &i.User.UpdatedAt, + &i.User.LastLogin, + &i.User.PhoneNumber, + &i.User.ProfilePicture, + &i.User.CreatedBy, + &i.User.EmailVerified, + &i.User.AvatarVerified, + &i.User.Verified, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getUserSessionsCount = `-- name: GetUserSessionsCount :one +SELECT COUNT(*) FROM user_sessions +` + +func (q *Queries) GetUserSessionsCount(ctx context.Context) (int64, error) { + row := q.db.QueryRow(ctx, getUserSessionsCount) + var count int64 + err := row.Scan(&count) + return count, err +} + +const listActiveUserSessions = `-- name: ListActiveUserSessions :many +SELECT id, user_id, session_type, issued_at, expires_at, last_active, ip_address, user_agent, access_token_id, refresh_token_id, device_info, is_active, revoked_at FROM user_sessions +WHERE user_id = $1 + AND is_active = TRUE +ORDER BY issued_at DESC +` + +func (q *Queries) ListActiveUserSessions(ctx context.Context, userID uuid.UUID) ([]UserSession, error) { + rows, err := q.db.Query(ctx, listActiveUserSessions, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []UserSession + for rows.Next() { + var i UserSession + if err := rows.Scan( + &i.ID, + &i.UserID, + &i.SessionType, + &i.IssuedAt, + &i.ExpiresAt, + &i.LastActive, + &i.IpAddress, + &i.UserAgent, + &i.AccessTokenID, + &i.RefreshTokenID, + &i.DeviceInfo, + &i.IsActive, + &i.RevokedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listAllSessions = `-- name: ListAllSessions :many +SELECT id, user_id, session_type, issued_at, expires_at, last_active, ip_address, user_agent, access_token_id, refresh_token_id, device_info, is_active, revoked_at FROM user_sessions +ORDER BY issued_at DESC +LIMIT $1 OFFSET $2 +` + +type ListAllSessionsParams struct { + Limit int32 `json:"limit"` + Offset int32 `json:"offset"` +} + +func (q *Queries) ListAllSessions(ctx context.Context, arg ListAllSessionsParams) ([]UserSession, error) { + rows, err := q.db.Query(ctx, listAllSessions, arg.Limit, arg.Offset) + if err != nil { + return nil, err + } + defer rows.Close() + var items []UserSession + for rows.Next() { + var i UserSession + if err := rows.Scan( + &i.ID, + &i.UserID, + &i.SessionType, + &i.IssuedAt, + &i.ExpiresAt, + &i.LastActive, + &i.IpAddress, + &i.UserAgent, + &i.AccessTokenID, + &i.RefreshTokenID, + &i.DeviceInfo, + &i.IsActive, + &i.RevokedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const revokeUserSession = `-- name: RevokeUserSession :exec +UPDATE user_sessions +SET is_active = FALSE, + revoked_at = NOW() +WHERE id = $1 + AND is_active = TRUE +` + +func (q *Queries) RevokeUserSession(ctx context.Context, id uuid.UUID) error { + _, err := q.db.Exec(ctx, revokeUserSession, id) + return err +} + +const updateSessionLastActive = `-- name: UpdateSessionLastActive :exec +UPDATE user_sessions +SET last_active = NOW() +WHERE id = $1 + AND is_active = TRUE +` + +func (q *Queries) UpdateSessionLastActive(ctx context.Context, id uuid.UUID) error { + _, err := q.db.Exec(ctx, updateSessionLastActive, id) + return err +} + +const updateSessionTokens = `-- name: UpdateSessionTokens :exec +UPDATE user_sessions +SET access_token_id = $2, refresh_token_id = $3, expires_at = $4 +WHERE id = $1 + AND is_active = TRUE +` + +type UpdateSessionTokensParams struct { + ID uuid.UUID `json:"id"` + AccessTokenID *uuid.UUID `json:"access_token_id"` + RefreshTokenID *uuid.UUID `json:"refresh_token_id"` + ExpiresAt *time.Time `json:"expires_at"` +} + +func (q *Queries) UpdateSessionTokens(ctx context.Context, arg UpdateSessionTokensParams) error { + _, err := q.db.Exec(ctx, updateSessionTokens, + arg.ID, + arg.AccessTokenID, + arg.RefreshTokenID, + arg.ExpiresAt, + ) + return err +} diff --git a/internal/types/apiservices.go b/internal/types/apiservices.go new file mode 100644 index 0000000..01c9e54 --- /dev/null +++ b/internal/types/apiservices.go @@ -0,0 +1,38 @@ +package types + +import ( + "time" + + "gitea.local/admin/hspguard/internal/repository" + "github.com/google/uuid" +) + +type ApiServiceDTO struct { + ID uuid.UUID `json:"id"` + ClientID string `json:"client_id"` + Name string `json:"name"` + Description *string `json:"description"` + IconUrl *string `json:"icon_url"` + RedirectUris []string `json:"redirect_uris"` + Scopes []string `json:"scopes"` + GrantTypes []string `json:"grant_types"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + IsActive bool `json:"is_active"` +} + +func NewApiServiceDTO(service repository.ApiService) ApiServiceDTO { + return ApiServiceDTO{ + ID: service.ID, + ClientID: service.ClientID, + Name: service.Name, + Description: service.Description, + IconUrl: service.IconUrl, + RedirectUris: service.RedirectUris, + Scopes: service.Scopes, + GrantTypes: service.GrantTypes, + CreatedAt: service.CreatedAt, + UpdatedAt: service.UpdatedAt, + IsActive: service.IsActive, + } +} diff --git a/internal/types/device.go b/internal/types/device.go new file mode 100644 index 0000000..45eb61f --- /dev/null +++ b/internal/types/device.go @@ -0,0 +1,12 @@ +package types + +type DeviceInfo struct { + DeviceType string `json:"device_type"` + OS string `json:"os"` + OSVersion string `json:"os_version"` + Browser string `json:"browser"` + BrowserVersion string `json:"browser_version"` + DeviceName string `json:"device_name"` + UserAgent string `json:"user_agent"` + Location string `json:"location"` +} diff --git a/internal/types/middleware.go b/internal/types/middleware.go index 14c15bf..ebadb0d 100644 --- a/internal/types/middleware.go +++ b/internal/types/middleware.go @@ -3,4 +3,4 @@ package types type contextKey string const UserIdKey contextKey = "userID" - +const JTIKey contextKey = "jti" diff --git a/internal/types/session.go b/internal/types/session.go new file mode 100644 index 0000000..26649c3 --- /dev/null +++ b/internal/types/session.go @@ -0,0 +1,29 @@ +package types + +import "gitea.local/admin/hspguard/internal/repository" + +type ServiceSessionDTO struct { + User UserDTO `json:"user"` + ApiService ApiServiceDTO `json:"api_service"` + repository.ServiceSession +} + +func NewServiceSessionDTO(row *repository.GetServiceSessionsRow) *ServiceSessionDTO { + return &ServiceSessionDTO{ + User: NewUserDTO(&row.User), + ApiService: NewApiServiceDTO(row.ApiService), + ServiceSession: row.ServiceSession, + } +} + +type UserSessionDTO struct { + User UserDTO `json:"user"` + repository.UserSession +} + +func NewUserSessionDTO(row *repository.GetUserSessionsRow) *UserSessionDTO { + return &UserSessionDTO{ + User: NewUserDTO(&row.User), + UserSession: row.UserSession, + } +} diff --git a/internal/types/token.go b/internal/types/token.go new file mode 100644 index 0000000..24e94d7 --- /dev/null +++ b/internal/types/token.go @@ -0,0 +1,21 @@ +package types + +import ( + "time" + + "github.com/google/uuid" +) + +type SignedToken struct { + Token string + ExpiresAt time.Time + ID uuid.UUID +} + +func NewSignedToken(token string, expiresAt time.Time, jti uuid.UUID) *SignedToken { + return &SignedToken{ + Token: token, + ExpiresAt: expiresAt, + ID: jti, + } +} diff --git a/internal/user/routes.go b/internal/user/routes.go index 26d7061..332fd3d 100644 --- a/internal/user/routes.go +++ b/internal/user/routes.go @@ -38,7 +38,7 @@ func NewUserHandler(repo *repository.Queries, minio *storage.FileStorage, cfg *c func (h *UserHandler) RegisterRoutes(api chi.Router) { api.Group(func(protected chi.Router) { - authMiddleware := imiddleware.NewAuthMiddleware(h.cfg) + authMiddleware := imiddleware.NewAuthMiddleware(h.cfg, h.repo) protected.Use(authMiddleware.Runner) protected.Put("/avatar", h.uploadAvatar) diff --git a/internal/util/location.go b/internal/util/location.go new file mode 100644 index 0000000..1b005ad --- /dev/null +++ b/internal/util/location.go @@ -0,0 +1,46 @@ +package util + +import ( + "encoding/json" + "log" + "net" + "net/http" + "strings" +) + +type LocationResult struct { + Country string `json:"country"` + Region string `json:"regionName"` + City string `json:"city"` +} + +func GetLocation(ip string) (LocationResult, error) { + var loc LocationResult + // Example using ipinfo.io free API + resp, err := http.Get("http://ip-api.com/json/" + ip + "?fields=25") + if err != nil { + return loc, err + } + defer resp.Body.Close() + json.NewDecoder(resp.Body).Decode(&loc) + return loc, nil +} + +func GetClientIP(r *http.Request) string { + // This header will be set by ngrok to the original client IP + if xff := r.Header.Get("X-Forwarded-For"); xff != "" { + log.Printf("DEBUG: Getting IP from X-Forwarded-For: %s\n", xff) + // X-Forwarded-For: client, proxy1, proxy2, ... + ips := strings.Split(xff, ",") + if len(ips) > 0 { + return strings.TrimSpace(ips[0]) + } + } + // Fallback to RemoteAddr (not the real client IP, but just in case) + host, _, err := net.SplitHostPort(r.RemoteAddr) + log.Printf("DEBUG: Falling to request remote addr: %s (%s)\n", host, r.RemoteAddr) + if err != nil { + return r.RemoteAddr + } + return host +} diff --git a/internal/util/request.go b/internal/util/request.go index 04d582e..0da7d6b 100644 --- a/internal/util/request.go +++ b/internal/util/request.go @@ -11,3 +11,7 @@ func GetRequestUserId(ctx context.Context) (string, bool) { return userId, ok } +func GetRequestJTI(ctx context.Context) (string, bool) { + jti, ok := ctx.Value(types.JTIKey).(string) + return jti, ok +} diff --git a/internal/util/session.go b/internal/util/session.go new file mode 100644 index 0000000..2c20a0e --- /dev/null +++ b/internal/util/session.go @@ -0,0 +1,39 @@ +package util + +import ( + "encoding/json" + "fmt" + "log" + + "gitea.local/admin/hspguard/internal/types" + "github.com/avct/uasurfer" +) + +func BuildDeviceInfo(userAgent string, remoteAddr string) []byte { + var deviceInfo types.DeviceInfo + + parsed := uasurfer.Parse(userAgent) + + deviceInfo.Browser = parsed.Browser.Name.StringTrimPrefix() + deviceInfo.BrowserVersion = fmt.Sprintf("%d.%d.%d", parsed.Browser.Version.Major, parsed.Browser.Version.Minor, parsed.Browser.Version.Patch) + deviceInfo.DeviceName = fmt.Sprintf("%s %s", parsed.OS.Platform.StringTrimPrefix(), parsed.OS.Name.StringTrimPrefix()) + deviceInfo.DeviceType = parsed.DeviceType.StringTrimPrefix() + deviceInfo.OS = parsed.OS.Platform.StringTrimPrefix() + deviceInfo.OSVersion = fmt.Sprintf("%d.%d.%d", parsed.OS.Version.Major, parsed.OS.Version.Minor, parsed.OS.Version.Patch) + deviceInfo.UserAgent = userAgent + + if location, err := GetLocation(remoteAddr); err != nil { + log.Printf("WARN: Failed to get location from ip (%s): %v\n", remoteAddr, err) + } else { + log.Printf("DEBUG: Response from IP fetcher: %#v\n", location) + deviceInfo.Location = fmt.Sprintf("%s, %s, %s", location.Country, location.Region, location.City) + } + + serialized, err := json.Marshal(deviceInfo) + if err != nil { + log.Printf("ERR: Failed to serialize device info: %v\n", err) + serialized = []byte{'{', '}'} + } + + return serialized +} diff --git a/migrations/00011_add_user_sessions_table.sql b/migrations/00011_add_user_sessions_table.sql new file mode 100644 index 0000000..1f1f994 --- /dev/null +++ b/migrations/00011_add_user_sessions_table.sql @@ -0,0 +1,34 @@ +-- +goose Up +-- +goose StatementBegin +CREATE TABLE user_sessions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid (), + user_id UUID REFERENCES users (id) NOT NULL, + session_type VARCHAR(32) NOT NULL DEFAULT 'user', -- e.g. 'user', 'admin' + issued_at TIMESTAMP + WITH + TIME ZONE NOT NULL DEFAULT NOW (), + expires_at TIMESTAMP + WITH + TIME ZONE, + last_active TIMESTAMP + WITH + TIME ZONE, + ip_address VARCHAR(45), -- supports IPv4/IPv6 + user_agent TEXT, + access_token_id UUID, + refresh_token_id UUID, + device_info JSONB, -- optional: structured info (browser, OS, etc.) + is_active BOOLEAN NOT NULL DEFAULT TRUE, + revoked_at TIMESTAMP + WITH + TIME ZONE +); + +CREATE INDEX IF NOT EXISTS idx_user_sessions_user_id ON user_sessions (user_id); + +-- +goose StatementEnd +-- +goose Down +-- +goose StatementBegin +DROP TABLE IF EXISTS user_sessions; + +-- +goose StatementEnd diff --git a/migrations/00012_add_service_sessions.sql b/migrations/00012_add_service_sessions.sql new file mode 100644 index 0000000..87b1feb --- /dev/null +++ b/migrations/00012_add_service_sessions.sql @@ -0,0 +1,38 @@ +-- +goose Up +-- +goose StatementBegin +CREATE TABLE service_sessions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid (), + service_id UUID REFERENCES api_services (id) NOT NULL, + client_id TEXT NOT NULL, + user_id UUID REFERENCES users (id), -- user on behalf of whom the service is acting, nullable for direct use with client creds + issued_at TIMESTAMP + WITH + TIME ZONE NOT NULL DEFAULT NOW (), + expires_at TIMESTAMP + WITH + TIME ZONE, + last_active TIMESTAMP + WITH + TIME ZONE, + ip_address VARCHAR(45), + user_agent TEXT, + access_token_id UUID, + refresh_token_id UUID, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + revoked_at TIMESTAMP + WITH + TIME ZONE, + scope TEXT, -- what scopes/permissions this session was issued for + claims JSONB -- snapshot of claims at session start, optional +); + +CREATE INDEX IF NOT EXISTS idx_service_sessions_client_id ON service_sessions (client_id); + +CREATE INDEX IF NOT EXISTS idx_service_sessions_user_id ON service_sessions (user_id); + +-- +goose StatementEnd +-- +goose Down +-- +goose StatementBegin +DROP TABLE IF EXISTS service_sessions; + +-- +goose StatementEnd diff --git a/queries/service_sessions.sql b/queries/service_sessions.sql new file mode 100644 index 0000000..99b460d --- /dev/null +++ b/queries/service_sessions.sql @@ -0,0 +1,69 @@ +-- name: CreateServiceSession :one +INSERT INTO service_sessions ( + service_id, client_id, user_id, issued_at, expires_at, last_active, + ip_address, user_agent, access_token_id, refresh_token_id, + is_active, scope, claims +) VALUES ( + $1, $2, $3, NOW(), $4, $5, + $6, $7, $8, $9, + TRUE, $10, $11 +) +RETURNING *; + +-- name: ListActiveServiceSessionsByClient :many +SELECT * FROM service_sessions +WHERE client_id = $1 + AND is_active = TRUE +ORDER BY issued_at DESC +LIMIT $1 OFFSET $2; + +-- name: ListActiveServiceSessionsByUser :many +SELECT * FROM service_sessions +WHERE user_id = $1 + AND is_active = TRUE +ORDER BY issued_at DESC +LIMIT $1 OFFSET $2; + +-- name: GetServiceSessionByAccessJTI :one +SELECT * FROM service_sessions +WHERE access_token_id = $1 + AND is_active = TRUE; + +-- name: GetServiceSessionByRefreshJTI :one +SELECT * FROM service_sessions +WHERE refresh_token_id = $1; + +-- name: RevokeServiceSession :exec +UPDATE service_sessions +SET is_active = FALSE, + revoked_at = NOW() +WHERE id = $1 + AND is_active = TRUE; + +-- name: UpdateServiceSessionLastActive :exec +UPDATE service_sessions +SET last_active = NOW() +WHERE id = $1 + AND is_active = TRUE; + +-- name: UpdateServiceSessionTokens :exec +UPDATE service_sessions +SET access_token_id = $2, refresh_token_id = $3, expires_at = $4 +WHERE id = $1 + AND is_active = TRUE; + +-- name: ListAllServiceSessions :many +SELECT * FROM service_sessions +ORDER BY issued_at DESC +LIMIT $1 OFFSET $2; + +-- name: GetServiceSessions :many +SELECT sqlc.embed(session), sqlc.embed(service), sqlc.embed(u) +FROM service_sessions AS session +JOIN api_services AS service ON service.id = session.service_id +JOIN users AS u ON u.id = session.user_id +ORDER BY session.issued_at DESC +LIMIT $1 OFFSET $2; + +-- name: GetServiceSessionsCount :one +SELECT COUNT(*) FROM service_sessions; diff --git a/queries/user_sessions.sql b/queries/user_sessions.sql new file mode 100644 index 0000000..61c7dca --- /dev/null +++ b/queries/user_sessions.sql @@ -0,0 +1,60 @@ +-- name: CreateUserSession :one +INSERT INTO user_sessions ( + user_id, session_type, issued_at, expires_at, last_active, + ip_address, user_agent, access_token_id, refresh_token_id, + device_info, is_active +) VALUES ( + $1, $2, NOW(), $3, $4, + $5, $6, $7, $8, + $9, TRUE +) +RETURNING *; + +-- name: ListActiveUserSessions :many +SELECT * FROM user_sessions +WHERE user_id = $1 + AND is_active = TRUE +ORDER BY issued_at DESC; + +-- name: GetUserSessionByAccessJTI :one +SELECT * FROM user_sessions +WHERE access_token_id = $1 + AND is_active = TRUE; + +-- name: GetUserSessionByRefreshJTI :one +SELECT * FROM user_sessions +WHERE refresh_token_id = $1; + +-- name: RevokeUserSession :exec +UPDATE user_sessions +SET is_active = FALSE, + revoked_at = NOW() +WHERE id = $1 + AND is_active = TRUE; + +-- name: UpdateSessionLastActive :exec +UPDATE user_sessions +SET last_active = NOW() +WHERE id = $1 + AND is_active = TRUE; + +-- name: UpdateSessionTokens :exec +UPDATE user_sessions +SET access_token_id = $2, refresh_token_id = $3, expires_at = $4 +WHERE id = $1 + AND is_active = TRUE; + +-- name: ListAllSessions :many +SELECT * FROM user_sessions +ORDER BY issued_at DESC +LIMIT $1 OFFSET $2; + +-- name: GetUserSessions :many +SELECT sqlc.embed(session), sqlc.embed(u) +FROM user_sessions AS session +JOIN users AS u ON u.id = session.user_id +ORDER BY session.issued_at DESC +LIMIT $1 OFFSET $2; + +-- name: GetUserSessionsCount :one +SELECT COUNT(*) FROM user_sessions; diff --git a/sqlc.yaml b/sqlc.yaml index e43b741..a76e898 100644 --- a/sqlc.yaml +++ b/sqlc.yaml @@ -41,20 +41,39 @@ sql: # ───── text ────────────────────────────────────────── - db_type: "pg_catalog.text" go_type: { type: "string" } - - db_type: "text" # or just "bool" + + - db_type: "text" go_type: { type: "string" } - db_type: "pg_catalog.text" nullable: true go_type: type: "string" - pointer: true # ⇒ *bool for NULLable columns + pointer: true - db_type: "text" nullable: true go_type: type: "string" - pointer: true # ⇒ *bool for NULLable columns + pointer: true + + - db_type: "pg_catalog.varchar" + go_type: { type: "string" } + + - db_type: "varchar" + go_type: { type: "string" } + + - db_type: "pg_catalog.varchar" + nullable: true + go_type: + type: "string" + pointer: true + + - db_type: "varchar" + nullable: true + go_type: + type: "string" + pointer: true # ───── timestamp (WITHOUT TZ) ──────────────────────── - db_type: "pg_catalog.timestamp" # or "timestamp" diff --git a/web/package-lock.json b/web/package-lock.json index 662c97c..34f17ee 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -12,6 +12,7 @@ "axios": "^1.9.0", "idb": "^8.0.3", "lucide-react": "^0.511.0", + "moment": "^2.30.1", "next-themes": "^0.4.6", "react": "^19.1.0", "react-dom": "^19.1.0", @@ -3809,6 +3810,15 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/moment": { + "version": "2.30.1", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", + "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==", + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", diff --git a/web/package.json b/web/package.json index 02f92fa..ddfa0a0 100644 --- a/web/package.json +++ b/web/package.json @@ -15,6 +15,7 @@ "axios": "^1.9.0", "idb": "^8.0.3", "lucide-react": "^0.511.0", + "moment": "^2.30.1", "next-themes": "^0.4.6", "react": "^19.1.0", "react-dom": "^19.1.0", diff --git a/web/src/App.tsx b/web/src/App.tsx index 87fd44b..fd0ca70 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -25,6 +25,8 @@ import VerifyEmailPage from "./pages/Verify/Email"; import VerifyEmailOtpPage from "./pages/Verify/Email/OTP"; import VerifyAvatarPage from "./pages/Verify/Avatar"; import VerifyReviewPage from "./pages/Verify/Review"; +import AdminUserSessionsPage from "./pages/Admin/UserSessions"; +import AdminServiceSessionsPage from "./pages/Admin/ServiceSessions"; const router = createBrowserRouter([ { @@ -81,6 +83,16 @@ const router = createBrowserRouter([ // }, ], }, + { + path: "user-sessions", + children: [{ index: true, element: }], + }, + { + path: "service-sessions", + children: [ + { index: true, element: }, + ], + }, ], }, ], diff --git a/web/src/api/admin/sessions.ts b/web/src/api/admin/sessions.ts new file mode 100644 index 0000000..c540eb8 --- /dev/null +++ b/web/src/api/admin/sessions.ts @@ -0,0 +1,78 @@ +import type { ServiceSession, UserSession } from "@/types"; +import { axios, handleApiError } from ".."; + +export interface FetchUserSessionsRequest { + page: number; + size: number; +} + +export interface FetchUserSessionsResponse { + items: UserSession[]; + page: number; + total_pages: number; +} + +export const adminGetUserSessionsApi = async ( + req: FetchUserSessionsRequest, +): Promise => { + const response = await axios.get( + "/api/v1/admin/user-sessions", + { + params: req, + }, + ); + + if (response.status !== 200 && response.status !== 201) + throw await handleApiError(response); + + return response.data; +}; + +export const adminRevokeUserSessionApi = async ( + sessionId: string, +): Promise => { + const response = await axios.patch( + `/api/v1/admin/user-sessions/revoke/${sessionId}`, + ); + + if (response.status !== 200 && response.status !== 201) + throw await handleApiError(response); +}; + +export interface FetchServiceSessionsRequest { + page: number; + size: number; +} + +export interface FetchServiceSessionsResponse { + items: ServiceSession[]; + page: number; + total_pages: number; +} + +export const adminGetServiceSessionsApi = async ( + req: FetchServiceSessionsRequest, +): Promise => { + const response = await axios.get( + "/api/v1/admin/service-sessions", + { + params: req, + }, + ); + + if (response.status !== 200 && response.status !== 201) + throw await handleApiError(response); + + return response.data; +}; + +export const adminRevokeServiceSessionApi = async ( + sessionId: string, +): Promise => { + const response = await axios.patch( + `/api/v1/admin/service-sessions/revoke/${sessionId}`, + ); + + if (response.status !== 200 && response.status !== 201) + throw await handleApiError(response); +}; diff --git a/web/src/api/index.ts b/web/src/api/index.ts index 2ecfa5f..124512e 100644 --- a/web/src/api/index.ts +++ b/web/src/api/index.ts @@ -27,11 +27,13 @@ const processRefreshQueue = async (token: string | null) => { const logout = async (accountId: string) => { const db = useDbStore.getState().db; - const requireSignIn = useAuth.getState().requireSignIn; + const { requireSignIn, loadAccounts } = useAuth.getState(); if (db) { await deleteAccount(db, accountId); } + await loadAccounts(); + requireSignIn?.(); }; diff --git a/web/src/api/signout.ts b/web/src/api/signout.ts new file mode 100644 index 0000000..19fc897 --- /dev/null +++ b/web/src/api/signout.ts @@ -0,0 +1,21 @@ +import axios from "axios"; +import { handleApiError } from "."; + +export const signoutApi = async (token: string) => { + const response = await axios.post( + "/api/v1/auth/signout", + {}, + { + headers: { + Authorization: `Bearer ${token}`, + }, + }, + ); + + if (response.status !== 200 && response.status !== 201) + throw await handleApiError(response); + + const data = response.data; + + return data; +}; diff --git a/web/src/components/Home/Sidebar/index.tsx b/web/src/components/Home/Sidebar/index.tsx index b68b9bc..dfba9e6 100644 --- a/web/src/components/Home/Sidebar/index.tsx +++ b/web/src/components/Home/Sidebar/index.tsx @@ -7,17 +7,32 @@ const Sidebar: FC = () => { return (
- {barItems.map((item) => ( - + {barItems.map((item, index) => + item.type !== "delimiter" ? ( + +
+ {item.icon} {item.title} +
+ + ) : (
- {item.icon} {item.title} +
+ {typeof item.title === "string" && ( +

+ {item.title} +

+ )} +
- - ))} + ), + )}
); }; diff --git a/web/src/components/Home/TopBar/index.tsx b/web/src/components/Home/TopBar/index.tsx index 97017a1..bcf56b5 100644 --- a/web/src/components/Home/TopBar/index.tsx +++ b/web/src/components/Home/TopBar/index.tsx @@ -7,19 +7,21 @@ const TopBar: FC = () => { return (
- {barItems.map((item) => ( - -
- {item.title} -
- - ))} + {barItems + .filter((item) => item.type !== "delimiter") + .map((item) => ( + +
+ {item.title} +
+ + ))}
); }; diff --git a/web/src/components/ui/pagination.tsx b/web/src/components/ui/pagination.tsx new file mode 100644 index 0000000..e239643 --- /dev/null +++ b/web/src/components/ui/pagination.tsx @@ -0,0 +1,64 @@ +import { ArrowLeft, ArrowRight } from "lucide-react"; +import React, { useCallback } from "react"; +import { Button } from "./button"; + +type PaginationProps = { + currentPage: number; + totalPages: number; + onPageChange: (page: number) => void; +}; + +const Pagination: React.FC = ({ + currentPage, + totalPages, + onPageChange, +}) => { + const getPageNumbers = useCallback(() => { + const delta = 2; + const pages = []; + + for ( + let i = Math.max(1, currentPage - delta); + i <= Math.min(totalPages, currentPage + delta); + i++ + ) { + pages.push(i); + } + + return pages; + }, [currentPage, totalPages]); + + if (totalPages <= 1) return null; + + return ( + + ); +}; + +export default Pagination; diff --git a/web/src/hooks/barItems.tsx b/web/src/hooks/barItems.tsx index 7361c83..086bbf0 100644 --- a/web/src/hooks/barItems.tsx +++ b/web/src/hooks/barItems.tsx @@ -1,21 +1,31 @@ import { useAuth } from "@/store/auth"; -import { Blocks, Home, Settings2, User, Users } from "lucide-react"; +import { Blocks, EarthLock, Home, User, UserLock, Users } from "lucide-react"; import { useCallback, type ReactNode } from "react"; import { useLocation } from "react-router"; +export interface BarDelimiter { + type: "delimiter"; + key: string; + title?: string; +} + export interface BarItem { + type?: "nav"; icon: ReactNode; title: string; tab: string; pathname: string; } -export const useBarItems = (): [BarItem[], (item: BarItem) => boolean] => { +export type Item = BarItem | BarDelimiter; + +export const useBarItems = (): [Item[], (item: Item) => boolean] => { const profile = useAuth((state) => state.profile); const location = useLocation(); const isActive = useCallback( - (item: BarItem) => { + (item: Item) => { + if (item.type === "delimiter") return false; if (item.pathname === "/") return location.pathname === item.pathname; return location.pathname.startsWith(item.pathname); }, @@ -28,6 +38,11 @@ export const useBarItems = (): [BarItem[], (item: BarItem) => boolean] => { return [ [ + { + type: "delimiter" as const, + title: "Basic", + key: "basic-del", + }, { icon: , title: "Home", @@ -40,14 +55,20 @@ export const useBarItems = (): [BarItem[], (item: BarItem) => boolean] => { tab: "personal-info", pathname: "/personal-info", }, - { - icon: , - title: "Data & Personalization", - tab: "data-personalization", - pathname: "/data-personalize", - }, + // TODO: + // { + // icon: , + // title: "Data & Personalization", + // tab: "data-personalization", + // pathname: "/data-personalize", + // }, ...(profile.is_admin ? [ + { + type: "delimiter" as const, + title: "Admin", + key: "admin-del", + }, { icon: , title: "API Services", @@ -60,6 +81,18 @@ export const useBarItems = (): [BarItem[], (item: BarItem) => boolean] => { tab: "admin.users", pathname: "/admin/users", }, + { + icon: , + title: "User Sessions", + tab: "admin.user-sessions", + pathname: "/admin/user-sessions", + }, + { + icon: , + title: "Service Sessions", + tab: "admin.service-sessions", + pathname: "/admin/service-sessions", + }, ] : []), ], diff --git a/web/src/pages/Admin/ApiServices/Create/index.tsx b/web/src/pages/Admin/ApiServices/Create/index.tsx index dcf0824..2580526 100644 --- a/web/src/pages/Admin/ApiServices/Create/index.tsx +++ b/web/src/pages/Admin/ApiServices/Create/index.tsx @@ -33,9 +33,9 @@ const ApiServiceCreatePage: FC = () => { const credentials = useApiServices((state) => state.createdCredentials); const onSubmit = useCallback( - (data: FormData) => { + async (data: FormData) => { console.log("Form submitted:", data); - createApiService({ + await createApiService({ name: data.name, description: data.description ?? "", redirect_uris: data.redirectUris.trim().split("\n"), @@ -45,6 +45,9 @@ const ApiServiceCreatePage: FC = () => { : ["authorization_code"], is_active: data.enabled, }); + // if (success) { + // navigate("/admin/api-services"); + // } }, [createApiService], ); diff --git a/web/src/pages/Admin/ServiceSessions/index.tsx b/web/src/pages/Admin/ServiceSessions/index.tsx new file mode 100644 index 0000000..88a2edc --- /dev/null +++ b/web/src/pages/Admin/ServiceSessions/index.tsx @@ -0,0 +1,214 @@ +import Breadcrumbs from "@/components/ui/breadcrumbs"; +import { Button } from "@/components/ui/button"; +import Avatar from "@/feature/Avatar"; +import { Ban } from "lucide-react"; +import { useCallback, useEffect, type FC } from "react"; +import { Link } from "react-router"; +import moment from "moment"; +import Pagination from "@/components/ui/pagination"; +import { useAuth } from "@/store/auth"; +import { useServiceSessions } from "@/store/admin/serviceSessions"; + +const AdminServiceSessionsPage: FC = () => { + const loading = useServiceSessions((s) => s.loading); + const sessions = useServiceSessions((s) => s.items); + + const page = useServiceSessions((s) => s.page); + const totalPages = useServiceSessions((s) => s.totalPages); + + const fetchSessions = useServiceSessions((s) => s.fetch); + const revokeSession = useServiceSessions((s) => s.revoke); + + const revokingId = useServiceSessions((s) => s.revokingId); + + const profile = useAuth((s) => s.profile); + + const handleRevokeSession = useCallback( + (id: string) => { + revokeSession(id); + }, + [revokeSession], + ); + + useEffect(() => { + fetchSessions(1); + }, [fetchSessions]); + + return ( +
+
+ +
+
+

Search...

+ {/* TODO: Filters */} +
+ +
+ + {loading && ( +
+
+ Loading... +
+
+ )} + + + + + + + + + + + + + + + {!loading && sessions.length === 0 ? ( + + + + ) : ( + sessions.map((session) => ( + + + + + + + + + + + + )) + )} + +
+ Service + + User + IP + + Status + + Issued At + + Expires At + + Last Active + + Revoked At + + Actions +
+ No sessions found. +
+ {/* */} + {typeof session.api_service?.icon_url === "string" && ( + + )} + +

+ {session.api_service?.name ?? session.client_id} +

+ +
+
+
+ {typeof session.user?.profile_picture === "string" && ( + + )} + +
+ +

+ {session.user?.full_name ?? ""}{" "} + {session.user_id === profile?.id ? "(You)" : ""} +

+ +

+ {session.ip_address ?? "No IP available"} +

+
+
+
+
+ + {session.is_active ? "Active" : "Inactive"} + {moment(session.expires_at).isSameOrBefore( + moment(new Date()), + ) && " (Expired)"} + + + {moment(session.issued_at).format("LLLL")} + + {session.expires_at + ? moment(session.expires_at).format("LLLL") + : "never"} + + {session.last_active + ? moment(session.last_active).format("LLLL") + : "never"} + + {session.revoked_at + ? new Date(session.revoked_at).toLocaleString() + : "never"} + +
+ +
+
+
+ fetchSessions(newPage)} + totalPages={totalPages} + /> +
+ ); +}; + +export default AdminServiceSessionsPage; diff --git a/web/src/pages/Admin/UserSessions/index.tsx b/web/src/pages/Admin/UserSessions/index.tsx new file mode 100644 index 0000000..6e3eb91 --- /dev/null +++ b/web/src/pages/Admin/UserSessions/index.tsx @@ -0,0 +1,209 @@ +import Breadcrumbs from "@/components/ui/breadcrumbs"; +import { Button } from "@/components/ui/button"; +import Avatar from "@/feature/Avatar"; +import type { DeviceInfo } from "@/types"; +import { Ban } from "lucide-react"; +import { useCallback, useEffect, useMemo, type FC } from "react"; +import { Link } from "react-router"; +import moment from "moment"; +import Pagination from "@/components/ui/pagination"; +import { useUserSessions } from "@/store/admin/userSessions"; +import { useAuth } from "@/store/auth"; + +const SessionSource: FC<{ deviceInfo: string }> = ({ deviceInfo }) => { + const parsed = useMemo( + () => JSON.parse(atob(deviceInfo)), + [deviceInfo], + ); + + return ( +

+ {parsed.os} {parsed.os_version} {parsed.browser} {parsed.browser_version} +

+ ); +}; + +const AdminUserSessionsPage: FC = () => { + const loading = useUserSessions((s) => s.loading); + const sessions = useUserSessions((s) => s.items); + + const page = useUserSessions((s) => s.page); + const totalPages = useUserSessions((s) => s.totalPages); + + const fetchSessions = useUserSessions((s) => s.fetch); + const revokeSession = useUserSessions((s) => s.revoke); + + const revokingId = useUserSessions((s) => s.revokingId); + + const profile = useAuth((s) => s.profile); + + const handleRevokeSession = useCallback( + (id: string) => { + revokeSession(id); + }, + [revokeSession], + ); + + useEffect(() => { + fetchSessions(1); + }, [fetchSessions]); + + return ( +
+
+ +
+
+

Search...

+ {/* TODO: Filters */} +
+ +
+ + {loading && ( +
+
+ Loading... +
+
+ )} + + + + + + + + + + + + + + + {!loading && sessions.length === 0 ? ( + + + + ) : ( + sessions.map((session) => ( + + + + + + + + + + + )) + )} + +
+ User + + Source + + Status + + Issued At + + Expires At + + Last Active + + Revoked At + + Actions +
+ No sessions found. +
+
+ {typeof session.user?.profile_picture === "string" && ( + + )} + + +

+ {session.user?.full_name ?? ""}{" "} + {session.user_id === profile?.id ? "(You)" : ""} +

+ +
+
+ + + + {session.is_active ? "Active" : "Inactive"} + {moment(session.expires_at).isSameOrBefore( + moment(new Date()), + ) && " (Expired)"} + + + {moment(session.issued_at).format("LLLL")} + + {session.expires_at + ? moment(session.expires_at).format("LLLL") + : "never"} + + {session.last_active + ? moment(session.last_active).format("LLLL") + : "never"} + + {session.revoked_at + ? new Date(session.revoked_at).toLocaleString() + : "never"} + +
+ +
+
+
+ fetchSessions(newPage)} + totalPages={totalPages} + /> +
+ ); +}; + +export default AdminUserSessionsPage; diff --git a/web/src/pages/Admin/Users/Create/index.tsx b/web/src/pages/Admin/Users/Create/index.tsx index d9d496c..4bbda4b 100644 --- a/web/src/pages/Admin/Users/Create/index.tsx +++ b/web/src/pages/Admin/Users/Create/index.tsx @@ -4,7 +4,7 @@ import { Input } from "@/components/ui/input"; import { useUsers } from "@/store/admin/users"; import { useCallback, type FC } from "react"; import { useForm } from "react-hook-form"; -import { Link } from "react-router"; +import { Link, useNavigate } from "react-router"; interface FormData { fullName: string; @@ -24,17 +24,22 @@ const AdminCreateUserPage: FC = () => { const createUser = useUsers((state) => state.createUser); + const navigate = useNavigate(); + const onSubmit = useCallback( - (data: FormData) => { + async (data: FormData) => { console.log("Form submitted:", data); - createUser({ + const success = await createUser({ email: data.email, full_name: data.fullName, password: data.password, is_admin: data.isAdmin, }); + if (success) { + navigate("/admin/users"); + } }, - [createUser], + [createUser, navigate], ); return ( diff --git a/web/src/pages/Login/index.tsx b/web/src/pages/Login/index.tsx index d29596d..2c03f52 100644 --- a/web/src/pages/Login/index.tsx +++ b/web/src/pages/Login/index.tsx @@ -62,8 +62,9 @@ export default function LoginPage() { } catch (err: any) { console.log(err); setError( - "Failed to create account. " + - (err.message ?? "Unexpected error happened"), + err.response?.data?.error ?? + err.message ?? + "Unexpected error happened", ); } finally { setLoading(false); diff --git a/web/src/store/admin/apiServices.ts b/web/src/store/admin/apiServices.ts index 3898679..baad0ed 100644 --- a/web/src/store/admin/apiServices.ts +++ b/web/src/store/admin/apiServices.ts @@ -22,7 +22,7 @@ interface IApiServicesState { fetch: () => Promise; fetchSingle: (id: string) => Promise; - create: (req: CreateApiServiceRequest) => Promise; + create: (req: CreateApiServiceRequest) => Promise; resetCredentials: () => void; toggling: boolean; @@ -117,11 +117,12 @@ export const useApiServices = create((set, get) => ({ try { const response = await postApiService(req); - set({ createdCredentials: response.credentials }); + set({ createdCredentials: response.credentials, creating: false }); + return true; } catch (err) { console.log("ERR: Failed to fetch services:", err); - } finally { set({ creating: false }); + return false; } }, })); diff --git a/web/src/store/admin/serviceSessions.ts b/web/src/store/admin/serviceSessions.ts new file mode 100644 index 0000000..e6e315b --- /dev/null +++ b/web/src/store/admin/serviceSessions.ts @@ -0,0 +1,59 @@ +import { + adminGetServiceSessionsApi, + adminRevokeServiceSessionApi, +} from "@/api/admin/sessions"; +import type { ServiceSession } from "@/types"; +import { create } from "zustand"; + +export const ADMIN_SERVICE_SESSIONS_PAGE_SIZE = 10; + +export interface IServiceSessionsState { + items: ServiceSession[]; + totalPages: number; + page: number; + + loading: boolean; + + revokingId: string | null; + + fetch: (page: number) => Promise; + revoke: (id: string) => Promise; +} + +export const useServiceSessions = create((set, get) => ({ + items: [], + totalPages: 0, + page: 1, + loading: false, + revokingId: null, + + fetch: async (page) => { + set({ loading: true, page }); + + try { + const response = await adminGetServiceSessionsApi({ + page, + size: ADMIN_SERVICE_SESSIONS_PAGE_SIZE, + }); + set({ items: response.items, totalPages: response.total_pages }); + } catch (err) { + console.log("ERR: Failed to fetch admin service sessions:", err); + } finally { + set({ loading: false }); + } + }, + + revoke: async (id) => { + set({ revokingId: id }); + + try { + await adminRevokeServiceSessionApi(id); + } catch (err) { + console.log("ERR: Failed to revoke service sessions:", err); + } finally { + set({ revokingId: null }); + const { fetch, page } = get(); + await fetch(page); + } + }, +})); diff --git a/web/src/store/admin/userSessions.ts b/web/src/store/admin/userSessions.ts new file mode 100644 index 0000000..ff1fd23 --- /dev/null +++ b/web/src/store/admin/userSessions.ts @@ -0,0 +1,59 @@ +import { + adminGetUserSessionsApi, + adminRevokeUserSessionApi, +} from "@/api/admin/sessions"; +import type { UserSession } from "@/types"; +import { create } from "zustand"; + +export const ADMIN_USER_SESSIONS_PAGE_SIZE = 10; + +export interface IUserSessionsState { + items: UserSession[]; + totalPages: number; + page: number; + + loading: boolean; + + revokingId: string | null; + + fetch: (page: number) => Promise; + revoke: (id: string) => Promise; +} + +export const useUserSessions = create((set, get) => ({ + items: [], + totalPages: 0, + page: 1, + loading: false, + revokingId: null, + + fetch: async (page) => { + set({ loading: true, page }); + + try { + const response = await adminGetUserSessionsApi({ + page, + size: ADMIN_USER_SESSIONS_PAGE_SIZE, + }); + set({ items: response.items, totalPages: response.total_pages }); + } catch (err) { + console.log("ERR: Failed to fetch admin user sessions:", err); + } finally { + set({ loading: false }); + } + }, + + revoke: async (id) => { + set({ revokingId: id }); + + try { + await adminRevokeUserSessionApi(id); + } catch (err) { + console.log("ERR: Failed to revoke user sessions:", err); + } finally { + set({ revokingId: null }); + const { fetch, page } = get(); + await fetch(page); + } + }, +})); diff --git a/web/src/store/admin/users.ts b/web/src/store/admin/users.ts index b984cf1..376a7fd 100644 --- a/web/src/store/admin/users.ts +++ b/web/src/store/admin/users.ts @@ -15,7 +15,7 @@ export interface IUsersState { fetchingCurrent: boolean; creating: boolean; - createUser: (req: CreateUserRequest) => Promise; + createUser: (req: CreateUserRequest) => Promise; fetchUsers: () => Promise; fetchUser: (id: string) => Promise; @@ -36,10 +36,12 @@ export const useUsers = create((set) => ({ try { const response = await postUser(req); console.log("INFO: User has been created:", response); + set({ creating: false }); + return true; } catch (err) { console.log("ERR: Failed to create user:", err); - } finally { set({ creating: false }); + return false; } }, diff --git a/web/src/types/index.ts b/web/src/types/index.ts index 0853c0a..1ffebc1 100644 --- a/web/src/types/index.ts +++ b/web/src/types/index.ts @@ -31,3 +31,50 @@ export interface ApiServiceCredentials { client_id: string; client_secret: string; } + +export interface ServiceSession { + id: string; + service_id: string; + api_service?: ApiService | null; + client_id: string; + user_id?: string | null; + user?: UserProfile | null; + issued_at: string; + expires_at?: string | null; + last_active?: string | null; + ip_address?: string | null; + user_agent?: string | null; + access_token_id?: string | null; + refresh_token_id?: string | null; + is_active: boolean; + revoked_at?: string | null; + scope?: string | null; + claims: string; // base64 encoded +} + +export interface UserSession { + id: string; + user_id: string; + user?: UserProfile | null; + session_type: string; // "user" | "admin" + issued_at: string; + expires_at?: string | null; + last_active?: string | null; + ip_address?: string | null; + user_agent?: string | null; + access_token_id?: string | null; + refresh_token_id?: string | null; + device_info: string; // base64 encoded + is_active: boolean; + revoked_at?: string | null; +} + +export interface DeviceInfo { + os: string; + os_version: string; + device_name: string; + device_type: string; + location: string; + browser: string; + browser_version: string; +}