diff --git a/pkg/models/api_tokens.go b/pkg/models/api_tokens.go index 410c4ac96..7739184fb 100644 --- a/pkg/models/api_tokens.go +++ b/pkg/models/api_tokens.go @@ -24,6 +24,7 @@ import ( "time" "code.vikunja.io/api/pkg/db" + "code.vikunja.io/api/pkg/events" "code.vikunja.io/api/pkg/user" "code.vikunja.io/api/pkg/utils" "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) - 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 { @@ -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 500 {object} models.Message "Internal error" // @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. _, 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. diff --git a/pkg/models/events.go b/pkg/models/events.go index fca768388..1996f54b8 100644 --- a/pkg/models/events.go +++ b/pkg/models/events.go @@ -395,3 +395,44 @@ type TimeEntryDeletedEvent struct { func (e *TimeEntryDeletedEvent) Name() string { 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" +} diff --git a/pkg/modules/auth/auth.go b/pkg/modules/auth/auth.go index 61a5f9b13..97429aa13 100644 --- a/pkg/modules/auth/auth.go +++ b/pkg/modules/auth/auth.go @@ -26,6 +26,8 @@ import ( "code.vikunja.io/api/pkg/config" "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/modules/humaecho5" "code.vikunja.io/api/pkg/user" @@ -123,6 +125,10 @@ func NewUserAuthTokenResponse(u *user.User, c *echo.Context, long bool) error { 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 // to the refresh endpoint, so the browser only sends it there. JavaScript // never sees the refresh token — this protects it from XSS. diff --git a/pkg/routes/api/v1/login.go b/pkg/routes/api/v1/login.go index eb92945d1..6c7eb686a 100644 --- a/pkg/routes/api/v1/login.go +++ b/pkg/routes/api/v1/login.go @@ -21,6 +21,8 @@ import ( "code.vikunja.io/api/pkg/config" "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/modules/auth" "code.vikunja.io/api/pkg/modules/auth/ldap" @@ -231,10 +233,14 @@ func Logout(c *echo.Context) (err error) { auth.ClearRefreshTokenCookie(c) var sid string + var userID int64 if raw := c.Get("user"); raw != nil { if jwtinf, ok := raw.(*jwt.Token); ok { if claims, ok := jwtinf.Claims.(jwt.MapClaims); ok { 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 } + 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."}) } diff --git a/pkg/routes/api_tokens.go b/pkg/routes/api_tokens.go index 0c9708849..4f146e5a7 100644 --- a/pkg/routes/api_tokens.go +++ b/pkg/routes/api_tokens.go @@ -21,6 +21,7 @@ import ( "strings" "code.vikunja.io/api/pkg/config" + "code.vikunja.io/api/pkg/events" "code.vikunja.io/api/pkg/log" "code.vikunja.io/api/pkg/models" "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_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 } diff --git a/pkg/user/events.go b/pkg/user/events.go index ff7866149..12b17a957 100644 --- a/pkg/user/events.go +++ b/pkg/user/events.go @@ -25,3 +25,34 @@ type CreatedEvent struct { func (t *CreatedEvent) Name() string { 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" +} diff --git a/pkg/user/user.go b/pkg/user/user.go index 1aec85853..ab2912982 100644 --- a/pkg/user/user.go +++ b/pkg/user/user.go @@ -27,6 +27,7 @@ import ( "code.vikunja.io/api/pkg/config" "code.vikunja.io/api/pkg/db" + "code.vikunja.io/api/pkg/events" "code.vikunja.io/api/pkg/log" "code.vikunja.io/api/pkg/modules/keyvalue" "code.vikunja.io/api/pkg/notifications" @@ -411,6 +412,10 @@ func (u *User) IsLocalUser() bool { } 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() err := keyvalue.IncrBy(key, 1) if err != nil {