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:
parent
4ff8181a47
commit
de22af0048
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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."})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue