feat(events): add auth boundary events

LoginSucceededEvent fires from NewUserAuthTokenResponse (the chokepoint
where local, LDAP and OIDC logins converge), LoginFailedEvent from
handleFailedPassword on every failed password check, LogoutEvent from
the logout handler, and APIToken issued/revoked/used events from the
token model and auth middleware. The token events carry IDs only since
the freshly created token struct holds the raw token string and the
poison queue logs message payloads.

None of these events have a listener yet — the audit registration adds
them. Dispatching to a topic without subscribers is a no-op.
This commit is contained in:
kolaente 2026-06-10 21:08:35 +02:00
parent 4ff8181a47
commit de22af0048
7 changed files with 131 additions and 3 deletions

View File

@ -24,6 +24,7 @@ import (
"time" "time"
"code.vikunja.io/api/pkg/db" "code.vikunja.io/api/pkg/db"
"code.vikunja.io/api/pkg/events"
"code.vikunja.io/api/pkg/user" "code.vikunja.io/api/pkg/user"
"code.vikunja.io/api/pkg/utils" "code.vikunja.io/api/pkg/utils"
"code.vikunja.io/api/pkg/web" "code.vikunja.io/api/pkg/web"
@ -121,7 +122,17 @@ func (t *APIToken) Create(s *xorm.Session, a web.Auth) (err error) {
} }
_, err = s.Insert(t) _, err = s.Insert(t)
return err if err != nil {
return err
}
events.DispatchOnCommit(s, &APITokenIssuedEvent{
TokenID: t.ID,
DoerID: a.GetID(),
OwnerID: t.OwnerID,
})
return nil
} }
func HashToken(token, salt string) string { func HashToken(token, salt string) string {
@ -192,10 +203,19 @@ func (t *APIToken) ReadAll(s *xorm.Session, a web.Auth, search string, page int,
// @Failure 404 {object} web.HTTPError "The token does not exist." // @Failure 404 {object} web.HTTPError "The token does not exist."
// @Failure 500 {object} models.Message "Internal error" // @Failure 500 {object} models.Message "Internal error"
// @Router /tokens/{tokenID} [delete] // @Router /tokens/{tokenID} [delete]
func (t *APIToken) Delete(s *xorm.Session, _ web.Auth) (err error) { func (t *APIToken) Delete(s *xorm.Session, a web.Auth) (err error) {
// Ownership is verified in CanDelete; delete by ID only. // Ownership is verified in CanDelete; delete by ID only.
_, err = s.Where("id = ?", t.ID).Delete(&APIToken{}) _, err = s.Where("id = ?", t.ID).Delete(&APIToken{})
return err if err != nil {
return err
}
events.DispatchOnCommit(s, &APITokenRevokedEvent{
TokenID: t.ID,
DoerID: a.GetID(),
})
return nil
} }
// HasCaldavAccess checks whether the token has the caldav access permission. // HasCaldavAccess checks whether the token has the caldav access permission.

View File

@ -395,3 +395,44 @@ type TimeEntryDeletedEvent struct {
func (e *TimeEntryDeletedEvent) Name() string { func (e *TimeEntryDeletedEvent) Name() string {
return "time-entry.deleted" return "time-entry.deleted"
} }
////////////////////
// API Token Events
// API token events carry IDs only: the freshly created token struct holds the
// raw token string, which must never end up in a message payload (the poison
// queue logs payloads on handler failure).
// APITokenIssuedEvent represents an API token being created
type APITokenIssuedEvent struct {
TokenID int64 `json:"token_id"`
DoerID int64 `json:"doer_id"`
OwnerID int64 `json:"owner_id"`
}
// Name defines the name for APITokenIssuedEvent
func (e *APITokenIssuedEvent) Name() string {
return "api-token.issued"
}
// APITokenRevokedEvent represents an API token being deleted
type APITokenRevokedEvent struct {
TokenID int64 `json:"token_id"`
DoerID int64 `json:"doer_id"`
}
// Name defines the name for APITokenRevokedEvent
func (e *APITokenRevokedEvent) Name() string {
return "api-token.revoked"
}
// APITokenUsedEvent represents an API token authenticating a request
type APITokenUsedEvent struct {
TokenID int64 `json:"token_id"`
OwnerID int64 `json:"owner_id"`
}
// Name defines the name for APITokenUsedEvent
func (e *APITokenUsedEvent) Name() string {
return "api-token.used"
}

View File

@ -26,6 +26,8 @@ import (
"code.vikunja.io/api/pkg/config" "code.vikunja.io/api/pkg/config"
"code.vikunja.io/api/pkg/db" "code.vikunja.io/api/pkg/db"
"code.vikunja.io/api/pkg/events"
"code.vikunja.io/api/pkg/log"
"code.vikunja.io/api/pkg/models" "code.vikunja.io/api/pkg/models"
"code.vikunja.io/api/pkg/modules/humaecho5" "code.vikunja.io/api/pkg/modules/humaecho5"
"code.vikunja.io/api/pkg/user" "code.vikunja.io/api/pkg/user"
@ -123,6 +125,10 @@ func NewUserAuthTokenResponse(u *user.User, c *echo.Context, long bool) error {
return err return err
} }
if err := events.DispatchWithContext(c.Request().Context(), &user.LoginSucceededEvent{User: u}); err != nil {
log.Errorf("Could not dispatch login succeeded event: %s", err)
}
// Set the refresh token as an HttpOnly cookie. The cookie is path-scoped // Set the refresh token as an HttpOnly cookie. The cookie is path-scoped
// to the refresh endpoint, so the browser only sends it there. JavaScript // to the refresh endpoint, so the browser only sends it there. JavaScript
// never sees the refresh token — this protects it from XSS. // never sees the refresh token — this protects it from XSS.

View File

@ -21,6 +21,8 @@ import (
"code.vikunja.io/api/pkg/config" "code.vikunja.io/api/pkg/config"
"code.vikunja.io/api/pkg/db" "code.vikunja.io/api/pkg/db"
"code.vikunja.io/api/pkg/events"
"code.vikunja.io/api/pkg/log"
"code.vikunja.io/api/pkg/models" "code.vikunja.io/api/pkg/models"
"code.vikunja.io/api/pkg/modules/auth" "code.vikunja.io/api/pkg/modules/auth"
"code.vikunja.io/api/pkg/modules/auth/ldap" "code.vikunja.io/api/pkg/modules/auth/ldap"
@ -231,10 +233,14 @@ func Logout(c *echo.Context) (err error) {
auth.ClearRefreshTokenCookie(c) auth.ClearRefreshTokenCookie(c)
var sid string var sid string
var userID int64
if raw := c.Get("user"); raw != nil { if raw := c.Get("user"); raw != nil {
if jwtinf, ok := raw.(*jwt.Token); ok { if jwtinf, ok := raw.(*jwt.Token); ok {
if claims, ok := jwtinf.Claims.(jwt.MapClaims); ok { if claims, ok := jwtinf.Claims.(jwt.MapClaims); ok {
sid, _ = claims["sid"].(string) sid, _ = claims["sid"].(string)
if id, ok := claims["id"].(float64); ok {
userID = int64(id)
}
} }
} }
} }
@ -257,5 +263,11 @@ func Logout(c *echo.Context) (err error) {
return err return err
} }
if userID != 0 {
if err := events.DispatchWithContext(c.Request().Context(), &user2.LogoutEvent{UserID: userID}); err != nil {
log.Errorf("Could not dispatch logout event: %s", err)
}
}
return c.JSON(http.StatusOK, models.Message{Message: "Successfully logged out."}) return c.JSON(http.StatusOK, models.Message{Message: "Successfully logged out."})
} }

View File

@ -21,6 +21,7 @@ import (
"strings" "strings"
"code.vikunja.io/api/pkg/config" "code.vikunja.io/api/pkg/config"
"code.vikunja.io/api/pkg/events"
"code.vikunja.io/api/pkg/log" "code.vikunja.io/api/pkg/log"
"code.vikunja.io/api/pkg/models" "code.vikunja.io/api/pkg/models"
"code.vikunja.io/api/pkg/modules/auth" "code.vikunja.io/api/pkg/modules/auth"
@ -89,5 +90,17 @@ func checkAPITokenAndPutItInContext(tokenHeaderValue string, c *echo.Context, sk
c.Set("api_token", token) c.Set("api_token", token)
c.Set("api_user", u) c.Set("api_user", u)
// Guarded by config: this fires on every token-authenticated request and
// only the audit listener consumes it.
if config.AuditEnabled.GetBool() {
err = events.DispatchWithContext(c.Request().Context(), &models.APITokenUsedEvent{
TokenID: token.ID,
OwnerID: token.OwnerID,
})
if err != nil {
log.Errorf("Could not dispatch api token used event: %s", err)
}
}
return nil return nil
} }

View File

@ -25,3 +25,34 @@ type CreatedEvent struct {
func (t *CreatedEvent) Name() string { func (t *CreatedEvent) Name() string {
return "user.created" return "user.created"
} }
// LoginSucceededEvent is fired after a user successfully authenticated,
// regardless of the auth provider (local, LDAP, OpenID).
type LoginSucceededEvent struct {
User *User `json:"user"`
}
// Name defines the name for LoginSucceededEvent
func (t *LoginSucceededEvent) Name() string {
return "user.login.succeeded"
}
// LoginFailedEvent is fired for every failed password check of a known user.
type LoginFailedEvent struct {
User *User `json:"user"`
}
// Name defines the name for LoginFailedEvent
func (t *LoginFailedEvent) Name() string {
return "user.login.failed"
}
// LogoutEvent is fired when a user destroys their session.
type LogoutEvent struct {
UserID int64 `json:"user_id"`
}
// Name defines the name for LogoutEvent
func (t *LogoutEvent) Name() string {
return "user.logout"
}

View File

@ -27,6 +27,7 @@ import (
"code.vikunja.io/api/pkg/config" "code.vikunja.io/api/pkg/config"
"code.vikunja.io/api/pkg/db" "code.vikunja.io/api/pkg/db"
"code.vikunja.io/api/pkg/events"
"code.vikunja.io/api/pkg/log" "code.vikunja.io/api/pkg/log"
"code.vikunja.io/api/pkg/modules/keyvalue" "code.vikunja.io/api/pkg/modules/keyvalue"
"code.vikunja.io/api/pkg/notifications" "code.vikunja.io/api/pkg/notifications"
@ -411,6 +412,10 @@ func (u *User) IsLocalUser() bool {
} }
func handleFailedPassword(user *User) { func handleFailedPassword(user *User) {
if err := events.Dispatch(&LoginFailedEvent{User: user}); err != nil {
log.Errorf("Could not dispatch login failed event: %s", err)
}
key := user.GetFailedPasswordAttemptsKey() key := user.GetFailedPasswordAttemptsKey()
err := keyvalue.IncrBy(key, 1) err := keyvalue.IncrBy(key, 1)
if err != nil { if err != nil {