Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions apps/api/internal/auth/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ type Service struct {
sessionStore *store.SessionStore
resetTokenStore *store.PasswordResetTokenStore
accountStore *store.AccountStore
apiTokenStore *store.ApiTokenStore
}

func NewService(userStore *store.UserStore, sessionStore *store.SessionStore, resetTokenStore *store.PasswordResetTokenStore) *Service {
Expand All @@ -74,6 +75,8 @@ func NewService(userStore *store.UserStore, sessionStore *store.SessionStore, re

func (s *Service) SetAccountStore(as *store.AccountStore) { s.accountStore = as }

func (s *Service) SetApiTokenStore(ts *store.ApiTokenStore) { s.apiTokenStore = ts }

type SignUpRequest struct {
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required,min=8"`
Expand Down Expand Up @@ -253,6 +256,37 @@ func (s *Service) UserFromSession(ctx context.Context, sessionKey string) (*mode
return user, nil
}

// ActiveUserByID returns the user if they exist and are active, otherwise
// nil. Used by API-token authentication so a deactivated user's still-valid
// token is rejected the same way a deactivated user's session is.
func (s *Service) ActiveUserByID(ctx context.Context, id uuid.UUID) (*model.User, error) {
user, err := s.userStore.GetByID(ctx, id)
if err != nil || user == nil || !user.IsActive {
return nil, nil
}
return user, nil
}

// UserFromAPIToken validates a bearer value as an API token (hashed +
// looked up, active/unexpired), returning the active user if valid, or nil
// if the value isn't a recognized token (not an error — callers should fall
// back to other bearer interpretations).
func (s *Service) UserFromAPIToken(ctx context.Context, plain string) (*model.User, error) {
if s.apiTokenStore == nil {
return nil, nil
}
tok, err := s.apiTokenStore.GetActiveByHash(ctx, store.HashToken(plain))
if err != nil || tok == nil {
return nil, nil
}
user, err := s.ActiveUserByID(ctx, tok.UserID)
if err != nil || user == nil {
return nil, nil
}
_ = s.apiTokenStore.UpdateLastUsed(ctx, tok.ID)
return user, nil
}

func (s *Service) UpdateProfile(ctx context.Context, u *model.User) error {
return s.userStore.Update(ctx, u)
}
Expand Down
41 changes: 39 additions & 2 deletions apps/api/internal/handler/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ type AuthHandler struct {
Ws *store.WorkspaceStore
NotifPrefs *store.UserNotificationPreferenceStore
ApiTokens *store.ApiTokenStore
InstanceAdmins *store.InstanceAdminStore
Queue *queue.Publisher
Redis *redis.Client
MagicCodeSecret string
Expand Down Expand Up @@ -120,19 +121,35 @@ func (h *AuthHandler) SignIn(c *gin.Context) {
return
}
}
sessionKey, user, err := h.Auth.SignIn(c.Request.Context(), auth.SignInRequest{Email: req.Email, Password: req.Password})
ctx := c.Request.Context()
emailNorm := strings.ToLower(strings.TrimSpace(req.Email))
signinFailKey := redis.PrefixRateLimit + "signinacctfail:" + emailNorm
if h.Redis != nil {
failCount, err := h.Redis.Count(ctx, signinFailKey)
if err == nil && failCount >= 10 {
c.JSON(http.StatusTooManyRequests, gin.H{"error": "Too many failed sign-in attempts for this account, please try again later"})
return
}
}
sessionKey, user, err := h.Auth.SignIn(ctx, auth.SignInRequest{Email: req.Email, Password: req.Password})
if err != nil {
if errors.Is(err, auth.ErrUserDeactivated) {
c.JSON(http.StatusForbidden, gin.H{"error": "Your account has been deactivated. Please contact the administrator.", "error_code": "USER_ACCOUNT_DEACTIVATED"})
return
}
if errors.Is(err, auth.ErrInvalidCredentials) {
if h.Redis != nil {
_, _ = h.Redis.Allow(ctx, signinFailKey, 10, 15*time.Minute)
}
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid email or password"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Sign in failed"})
return
}
if h.Redis != nil {
_ = h.Redis.Delete(ctx, signinFailKey)
}
setSessionCookie(c, sessionKey)
c.JSON(http.StatusOK, userResponse(user))
}
Expand Down Expand Up @@ -223,7 +240,12 @@ func (h *AuthHandler) Me(c *gin.Context) {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"})
return
}
c.JSON(http.StatusOK, userResponse(user))
resp := userResponse(user)
if h.InstanceAdmins != nil {
isAdmin, _ := h.InstanceAdmins.IsAdmin(c.Request.Context(), user.ID)
resp["is_instance_admin"] = isAdmin
}
c.JSON(http.StatusOK, resp)
}

// UpdateMeRequest is the body for PATCH /api/users/me/
Expand Down Expand Up @@ -884,6 +906,19 @@ func (h *AuthHandler) MagicCodeVerify(c *gin.Context) {
return
}

// Failed-verify attempts are tracked on a key independent of the code's
// own TTL/Attempts, so requesting a new code does not reset this lockout.
failCount, err := h.Redis.MagicCodeVerifyFailCount(ctx, body.Email)
if err != nil {
h.log().Error("magic code verify-fail count", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Verification failed"})
return
}
if failCount >= redis.MagicCodeVerifyFailMax {
c.JSON(http.StatusTooManyRequests, gin.H{"error": "Too many incorrect attempts. Please request a new code later."})
return
}

stored, err := h.Redis.GetMagicCodeLogin(ctx, body.Email)
if err != nil {
h.log().Error("magic code redis get", "error", err)
Expand All @@ -898,6 +933,7 @@ func (h *AuthHandler) MagicCodeVerify(c *gin.Context) {
tryMAC := auth.MagicCodeHMAC(h.MagicCodeSecret, body.Email, body.Code)
if subtle.ConstantTimeCompare([]byte(stored.CodeMAC), []byte(tryMAC)) != 1 {
_ = h.Redis.BumpMagicCodeLoginFailedAttempt(ctx, body.Email)
_ = h.Redis.BumpMagicCodeVerifyFail(ctx, body.Email)
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid or expired code"})
return
}
Expand All @@ -909,6 +945,7 @@ func (h *AuthHandler) MagicCodeVerify(c *gin.Context) {
}

_ = h.Redis.DeleteMagicCodeLogin(ctx, body.Email)
_ = h.Redis.ResetMagicCodeVerifyFail(ctx, body.Email)

if stored.IsSignup {
sessionKey, user, err := h.Auth.SignUpMagic(ctx, body.Email, body.FirstName, body.LastName)
Expand Down
106 changes: 106 additions & 0 deletions apps/api/internal/handler/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,21 @@ func TestAuth_Me_ReturnsUser(t *testing.T) {
body := testutil.MustJSONMap(t, rr)
assert.Equal(t, "me@test.local", body["email"])
assert.Equal(t, user.ID.String(), body["id"])
assert.Equal(t, false, body["is_instance_admin"])
}

// TestAuth_Me_IsInstanceAdminTrueForAdmin proves /api/users/me/ reports
// is_instance_admin: true for an actual instance admin (#163's frontend fix
// gates the instance-admin UI on this field).
func TestAuth_Me_IsInstanceAdminTrueForAdmin(t *testing.T) {
ts := testutil.NewTestServer(t)
user := testutil.CreateUser(t, ts.DB)
testutil.SeedInstanceAdmin(t, ts.DB, user)
session := testutil.LoginAs(t, ts.DB, user)

rr := ts.GET("/api/users/me/", session)
require.Equal(t, http.StatusOK, rr.Code, "body=%s", rr.Body.String())
assert.Equal(t, true, testutil.MustJSONMap(t, rr)["is_instance_admin"])
}

func TestAuth_UpdateMe_PatchProfile(t *testing.T) {
Expand Down Expand Up @@ -303,6 +318,97 @@ func TestAuth_Tokens_RequiresAuth(t *testing.T) {
require.Equal(t, http.StatusUnauthorized, rr.Code)
}

// TestAuth_ApiToken_AuthenticatesRequests proves an API token created via
// the tokens endpoint actually authenticates requests (the bug in #162 was
// that tokens were issued but never accepted by RequireAuth).
func TestAuth_ApiToken_AuthenticatesRequests(t *testing.T) {
ts := testutil.NewTestServer(t)
user := testutil.CreateUser(t, ts.DB)
session := testutil.LoginAs(t, ts.DB, user)

rr := ts.POST("/api/users/me/tokens/", map[string]any{"label": "ci-token"}, session)
require.Equal(t, http.StatusCreated, rr.Code, "body=%s", rr.Body.String())
plainToken, _ := testutil.MustJSONMap(t, rr)["token"].(string)
require.NotEmpty(t, plainToken)

rr2 := ts.DoWithHeaders(http.MethodGet, "/api/users/me/", nil, http.Header{
"Authorization": []string{"Bearer " + plainToken},
})
require.Equal(t, http.StatusOK, rr2.Code, "body=%s", rr2.Body.String())
assert.Equal(t, user.ID.String(), testutil.MustJSONMap(t, rr2)["id"])
}

// TestAuth_ApiToken_ValidEvenWithStaleCookiePresent proves a stale/invalid
// session cookie sent alongside a valid API token Bearer header doesn't mask
// the token (a bug caught in code review: an if/else-if between cookie and
// bearer meant any cookie, even an invalid one, skipped the bearer check).
func TestAuth_ApiToken_ValidEvenWithStaleCookiePresent(t *testing.T) {
ts := testutil.NewTestServer(t)
user := testutil.CreateUser(t, ts.DB)
session := testutil.LoginAs(t, ts.DB, user)

rr := ts.POST("/api/users/me/tokens/", map[string]any{"label": "ci-token"}, session)
require.Equal(t, http.StatusCreated, rr.Code, "body=%s", rr.Body.String())
plainToken, _ := testutil.MustJSONMap(t, rr)["token"].(string)
require.NotEmpty(t, plainToken)

rr2 := ts.DoWithHeaders(http.MethodGet, "/api/users/me/", nil, http.Header{
"Authorization": []string{"Bearer " + plainToken},
"Cookie": []string{"session_id=this-is-not-a-real-session"},
})
require.Equal(t, http.StatusOK, rr2.Code, "body=%s", rr2.Body.String())
assert.Equal(t, user.ID.String(), testutil.MustJSONMap(t, rr2)["id"])
}

// TestAuth_ApiToken_RevokedRejected proves a revoked token no longer
// authenticates.
func TestAuth_ApiToken_RevokedRejected(t *testing.T) {
ts := testutil.NewTestServer(t)
user := testutil.CreateUser(t, ts.DB)
session := testutil.LoginAs(t, ts.DB, user)

rr := ts.POST("/api/users/me/tokens/", map[string]any{"label": "ci-token"}, session)
require.Equal(t, http.StatusCreated, rr.Code, "body=%s", rr.Body.String())
createdBody := testutil.MustJSONMap(t, rr)
plainToken, _ := createdBody["token"].(string)
require.NotEmpty(t, plainToken)

listRR := ts.GET("/api/users/me/tokens/", session)
tokens, _ := testutil.MustJSONMap(t, listRR)["tokens"].([]any)
require.Len(t, tokens, 1)
tokenID, _ := tokens[0].(map[string]any)["id"].(string)
require.NotEmpty(t, tokenID)

revokeRR := ts.DELETE("/api/users/me/tokens/"+tokenID+"/", session)
require.Equal(t, http.StatusNoContent, revokeRR.Code)

rr2 := ts.DoWithHeaders(http.MethodGet, "/api/users/me/", nil, http.Header{
"Authorization": []string{"Bearer " + plainToken},
})
require.Equal(t, http.StatusUnauthorized, rr2.Code, "body=%s", rr2.Body.String())
}

// TestAuth_ApiToken_DeactivatedUserRejected proves a deactivated user's
// still-valid API token is rejected, mirroring the #155 protection for
// cookie sessions.
func TestAuth_ApiToken_DeactivatedUserRejected(t *testing.T) {
ts := testutil.NewTestServer(t)
user := testutil.CreateUser(t, ts.DB)
session := testutil.LoginAs(t, ts.DB, user)

rr := ts.POST("/api/users/me/tokens/", map[string]any{"label": "ci-token"}, session)
require.Equal(t, http.StatusCreated, rr.Code, "body=%s", rr.Body.String())
plainToken, _ := testutil.MustJSONMap(t, rr)["token"].(string)
require.NotEmpty(t, plainToken)

require.NoError(t, ts.DB.Exec("UPDATE users SET is_active = false WHERE id = ?", user.ID).Error)

rr2 := ts.DoWithHeaders(http.MethodGet, "/api/users/me/", nil, http.Header{
"Authorization": []string{"Bearer " + plainToken},
})
require.Equal(t, http.StatusUnauthorized, rr2.Code, "body=%s", rr2.Body.String())
}

func TestAuth_ForgotPassword_NoSMTPReturns503(t *testing.T) {
ts := testutil.NewTestServer(t)
testutil.CreateUser(t, ts.DB, testutil.WithUserEmail("forgot@test.local"))
Expand Down
Loading
Loading