feat: Add audit logging (#38189)

Co-authored-by: bircni <bircni@users.noreply.github.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
bircni
2026-09-12 10:15:23 +02:00
committed by GitHub
parent 4d43445532
commit da37b7916b
136 changed files with 3864 additions and 209 deletions
+7 -1
View File
@@ -9,6 +9,7 @@ import (
"net/http"
actions_model "gitea.dev/models/actions"
audit_model "gitea.dev/models/audit"
auth_model "gitea.dev/models/auth"
user_model "gitea.dev/models/user"
"gitea.dev/modules/auth/httpauth"
@@ -16,6 +17,7 @@ import (
"gitea.dev/modules/setting"
"gitea.dev/modules/timeutil"
"gitea.dev/modules/util"
"gitea.dev/services/audit"
)
// Ensure the struct implements the interface.
@@ -71,7 +73,7 @@ func parseAuthBasic(req *http.Request) (ret struct{ authToken, uname, passwd str
// VerifyAuthToken only the access token provided as parameter, used by other auth methods that want to reuse access token verification logic
func (b *Basic) VerifyAuthToken(req *http.Request, w http.ResponseWriter, store DataStore, sess SessionStore, authToken string) (*user_model.User, error) {
// get oauth2 token's user's ID
accessTokenScope, uid := GetOAuthAccessTokenScopeAndUserID(req.Context(), authToken)
accessTokenScope, uid, grantID := GetOAuthAccessTokenScopeAndUserID(req.Context(), authToken)
if uid != 0 {
log.Trace("Basic Authorization: Valid OAuthAccessToken for user[%d]", uid)
@@ -83,6 +85,7 @@ func (b *Basic) VerifyAuthToken(req *http.Request, w http.ResponseWriter, store
store.GetData()["LoginMethod"] = OAuth2TokenMethodName
store.GetData()["ApiTokenScope"] = accessTokenScope
setAuthCredential(store, credentialOAuth2Grant, grantID)
return u, nil
}
@@ -103,6 +106,7 @@ func (b *Basic) VerifyAuthToken(req *http.Request, w http.ResponseWriter, store
store.GetData()["LoginMethod"] = AccessTokenMethodName
store.GetData()["ApiTokenScope"] = token.Scope
setAuthCredential(store, credentialAccessToken, token.ID)
return u, nil
} else if !errors.Is(err, util.ErrNotExist) {
log.Error("GetAccessTokenBySHA: %v", err)
@@ -180,6 +184,8 @@ func validateTOTP(req *http.Request, u *user_model.User) error {
if ok, err := twofa.ValidateAndConsumeTOTP(req.Context(), req.Header.Get("X-Gitea-OTP")); err != nil {
return err
} else if !ok {
audit.RecordAs(req.Context(), u, audit_model.UserAuthenticationFailTwoFactor, u)
return util.NewInvalidArgumentErrorf("invalid provided OTP")
}
return nil
+21
View File
@@ -0,0 +1,21 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package auth
import (
"strconv"
"gitea.dev/modules/web/middleware"
)
// Credential kinds naming how a request authenticated, recorded so an audit
// event can point at the token rather than only at its owner.
const (
credentialAccessToken = "access-token"
credentialOAuth2Grant = "oauth2-grant"
)
func setAuthCredential(store DataStore, kind string, id int64) {
store.GetData()[middleware.ContextDataKeyAuthCredential] = kind + ":" + strconv.FormatInt(id, 10)
}
+11
View File
@@ -42,6 +42,17 @@ func ImpersonateUser(sess SessionStore, u *user_model.User) error {
return sess.Release()
}
// ImpersonatorUserID returns the ID of the admin behind an impersonated
// session, or zero when the session is not impersonating anyone.
func ImpersonatorUserID(sess SessionStore) int64 {
data, ok := sess.Get(session.KeyImpersonatorData).(map[string]any)
if !ok {
return 0
}
uid, _ := data[session.KeyUID].(int64)
return uid
}
func ExitImpersonatedUser(sess SessionStore) (bool, error) {
impersonatorData, ok := sess.Get(session.KeyImpersonatorData).(map[string]any)
if !ok {
+13 -10
View File
@@ -25,35 +25,36 @@ import (
var _ Method = &OAuth2{}
// GetOAuthAccessTokenScopeAndUserID returns access token scope and user id
func GetOAuthAccessTokenScopeAndUserID(ctx context.Context, accessToken string) (auth_model.AccessTokenScope, int64) {
// GetOAuthAccessTokenScopeAndUserID returns access token scope, user id and the
// grant the token was issued for.
func GetOAuthAccessTokenScopeAndUserID(ctx context.Context, accessToken string) (_ auth_model.AccessTokenScope, userID, grantID int64) {
var accessTokenScope auth_model.AccessTokenScope
if !setting.OAuth2.Enabled {
return accessTokenScope, 0
return accessTokenScope, 0, 0
}
// JWT tokens require a ".", if the token isn't like that, return early
if !strings.Contains(accessToken, ".") {
return accessTokenScope, 0
return accessTokenScope, 0, 0
}
token, err := oauth2_provider.ParseToken(accessToken, oauth2_provider.DefaultSigningKey)
if err != nil {
log.Trace("oauth2.ParseToken: %v", err)
return accessTokenScope, 0
return accessTokenScope, 0, 0
}
var grant *auth_model.OAuth2Grant
if grant, err = auth_model.GetOAuth2GrantByID(ctx, token.GrantID); err != nil || grant == nil {
return accessTokenScope, 0
return accessTokenScope, 0, 0
}
if token.Kind != oauth2_provider.KindAccessToken {
return accessTokenScope, 0
return accessTokenScope, 0, 0
}
if token.ExpiresAt.Before(time.Now()) || token.IssuedAt.After(time.Now()) {
return accessTokenScope, 0
return accessTokenScope, 0, 0
}
accessTokenScope = oauth2_provider.GrantAdditionalScopes(grant.Scope)
return accessTokenScope, grant.UserID
return accessTokenScope, grant.UserID, grant.ID
}
// CheckTaskIsRunning verifies that the TaskID corresponds to a running task
@@ -118,9 +119,10 @@ func (o *OAuth2) userFromToken(ctx context.Context, tokenSHA string, store DataS
}
// Otherwise, check if this is an OAuth access token
accessTokenScope, uid := GetOAuthAccessTokenScopeAndUserID(ctx, tokenSHA)
accessTokenScope, uid, grantID := GetOAuthAccessTokenScopeAndUserID(ctx, tokenSHA)
if uid != 0 {
store.GetData()["ApiTokenScope"] = accessTokenScope
setAuthCredential(store, credentialOAuth2Grant, grantID)
}
return user_model.GetUserByID(ctx, uid)
}
@@ -141,6 +143,7 @@ func (o *OAuth2) userFromToken(ctx context.Context, tokenSHA string, store DataS
log.Error("UpdateAccessToken: %v", err)
}
store.GetData()["ApiTokenScope"] = t.Scope
setAuthCredential(store, credentialAccessToken, t.ID)
return user_model.GetUserByID(ctx, t.UID)
}
+4
View File
@@ -9,11 +9,13 @@ import (
"strings"
"uuid"
audit_model "gitea.dev/models/audit"
user_model "gitea.dev/models/user"
"gitea.dev/modules/log"
"gitea.dev/modules/optional"
"gitea.dev/modules/session"
"gitea.dev/modules/setting"
"gitea.dev/services/audit"
)
// Ensure the struct implements the interface.
@@ -171,5 +173,7 @@ func (r *ReverseProxy) newUser(req *http.Request) *user_model.User {
return nil
}
audit.RecordAs(req.Context(), user_model.NewAuthenticationSourceUser(), audit_model.UserCreate, user)
return user
}
+33 -2
View File
@@ -6,11 +6,37 @@ package auth
import (
"context"
audit_model "gitea.dev/models/audit"
"gitea.dev/models/auth"
"gitea.dev/models/db"
user_model "gitea.dev/models/user"
"gitea.dev/services/audit"
)
// CreateSource creates a AuthSource record in DB.
func CreateSource(ctx context.Context, source *auth.Source) error {
if err := auth.CreateSource(ctx, source); err != nil {
return err
}
audit.Record(ctx, audit_model.SystemAuthenticationSourceAdd, nil,
"auth_source", source.Name, "auth_source_type", source.Type.String(), "is_active", source.IsActive)
return nil
}
// UpdateSource updates a AuthSource record in DB.
func UpdateSource(ctx context.Context, source *auth.Source) error {
if err := auth.UpdateSource(ctx, source); err != nil {
return err
}
audit.Record(ctx, audit_model.SystemAuthenticationSourceUpdate, nil,
"auth_source", source.Name, "auth_source_type", source.Type.String(), "is_active", source.IsActive)
return nil
}
// DeleteSource deletes a AuthSource record in DB.
func DeleteSource(ctx context.Context, source *auth.Source) error {
count, err := db.GetEngine(ctx).Count(&user_model.User{LoginSource: source.ID})
@@ -37,6 +63,11 @@ func DeleteSource(ctx context.Context, source *auth.Source) error {
}
}
_, err = db.GetEngine(ctx).ID(source.ID).Delete(new(auth.Source))
return err
if _, err = db.GetEngine(ctx).ID(source.ID).Delete(new(auth.Source)); err != nil {
return err
}
audit.Record(ctx, audit_model.SystemAuthenticationSourceRemove, nil, "auth_source", source.Name)
return nil
}
@@ -8,12 +8,14 @@ import (
"strings"
asymkey_model "gitea.dev/models/asymkey"
audit_model "gitea.dev/models/audit"
"gitea.dev/models/auth"
user_model "gitea.dev/models/user"
auth_module "gitea.dev/modules/auth"
"gitea.dev/modules/log"
"gitea.dev/modules/optional"
asymkey_service "gitea.dev/services/asymkey"
"gitea.dev/services/audit"
source_service "gitea.dev/services/auth/source"
user_service "gitea.dev/services/user"
)
@@ -21,6 +23,8 @@ import (
// Authenticate queries if login/password is valid against the LDAP directory pool,
// and create a local user if success when enabled.
func (source *Source) Authenticate(ctx context.Context, user *user_model.User, userName, password string) (*user_model.User, error) {
ctx = audit.WithDoer(ctx, user_model.NewAuthenticationSourceUser())
loginName := userName
if user != nil {
loginName = user.LoginName
@@ -99,6 +103,8 @@ func (source *Source) Authenticate(ctx context.Context, user *user_model.User, u
return user, err
}
audit.Record(ctx, audit_model.UserCreate, user)
if isAttributeSSHPublicKeySet && asymkey_model.AddPublicKeysBySource(ctx, user, source.AuthSource, sr.SSHPublicKey, source.SSHKeysAreVerified) {
if err := asymkey_service.RewriteAllPublicKeys(ctx); err != nil {
return user, err
+8
View File
@@ -8,6 +8,7 @@ import (
"strings"
asymkey_model "gitea.dev/models/asymkey"
audit_model "gitea.dev/models/audit"
"gitea.dev/models/db"
"gitea.dev/models/organization"
user_model "gitea.dev/models/user"
@@ -16,6 +17,7 @@ import (
"gitea.dev/modules/log"
"gitea.dev/modules/optional"
asymkey_service "gitea.dev/services/asymkey"
"gitea.dev/services/audit"
source_service "gitea.dev/services/auth/source"
user_service "gitea.dev/services/user"
)
@@ -24,6 +26,10 @@ import (
func (source *Source) Sync(ctx context.Context, updateExisting bool) error {
log.Trace("Doing: SyncExternalUsers[%s]", source.AuthSource.Name)
// everything this sync changes is attributed to the authentication source,
// not to a signed-in user
ctx = audit.WithDoer(ctx, user_model.NewAuthenticationSourceUser())
isAttributeSSHPublicKeySet := strings.TrimSpace(source.AttributeSSHPublicKey) != ""
var sshKeysNeedUpdate bool
@@ -131,6 +137,8 @@ func (source *Source) Sync(ctx context.Context, updateExisting bool) error {
err = user_model.CreateUser(ctx, usr, &user_model.Meta{}, overwriteDefault)
if err != nil {
log.Error("SyncExternalUsers[%s]: Error creating user %s: %v", source.AuthSource.Name, su.Username, err)
} else {
audit.Record(ctx, audit_model.UserCreate, usr)
}
if err == nil && isAttributeSSHPublicKeySet {
@@ -9,11 +9,13 @@ import (
"strings"
"uuid"
audit_model "gitea.dev/models/audit"
"gitea.dev/models/auth"
user_model "gitea.dev/models/user"
"gitea.dev/modules/auth/pam"
"gitea.dev/modules/optional"
"gitea.dev/modules/setting"
"gitea.dev/services/audit"
)
// Authenticate queries if login/password is valid against the PAM,
@@ -66,5 +68,7 @@ func (source *Source) Authenticate(ctx context.Context, user *user_model.User, u
return user, err
}
audit.RecordAs(ctx, user_model.NewAuthenticationSourceUser(), audit_model.UserCreate, user)
return user, nil
}
@@ -10,10 +10,12 @@ import (
"net/textproto"
"strings"
audit_model "gitea.dev/models/audit"
auth_model "gitea.dev/models/auth"
user_model "gitea.dev/models/user"
"gitea.dev/modules/optional"
"gitea.dev/modules/util"
"gitea.dev/services/audit"
)
// Authenticate queries if the provided login/password is authenticates against the SMTP server
@@ -83,5 +85,7 @@ func (source *Source) Authenticate(ctx context.Context, user *user_model.User, u
return user, err
}
audit.RecordAs(ctx, user_model.NewAuthenticationSourceUser(), audit_model.UserCreate, user)
return user, nil
}
@@ -12,6 +12,7 @@ import (
user_model "gitea.dev/models/user"
"gitea.dev/modules/container"
"gitea.dev/modules/log"
"gitea.dev/services/audit"
org_service "gitea.dev/services/org"
)
@@ -31,6 +32,9 @@ func SyncGroupsToTeams(ctx context.Context, user *user_model.User, sourceUserGro
// SyncGroupsToTeamsCached maps authentication source groups to organization and team memberships
func SyncGroupsToTeamsCached(ctx context.Context, user *user_model.User, sourceUserGroups container.Set[string], sourceGroupTeamMapping map[string]map[string][]string, performRemoval bool, orgCache map[string]*organization.Organization, teamCache map[string]*organization.Team) error {
// team membership changes here come from the authentication source mapping
ctx = audit.WithDoer(ctx, user_model.NewAuthenticationSourceUser())
membershipsToAdd, membershipsToRemove := resolveMappedMemberships(sourceUserGroups, sourceGroupTeamMapping)
if performRemoval {
+4
View File
@@ -11,6 +11,7 @@ import (
"sync"
"uuid"
audit_model "gitea.dev/models/audit"
"gitea.dev/models/auth"
"gitea.dev/models/db"
user_model "gitea.dev/models/user"
@@ -18,6 +19,7 @@ import (
"gitea.dev/modules/optional"
"gitea.dev/modules/setting"
"gitea.dev/modules/templates"
"gitea.dev/services/audit"
"gitea.dev/services/auth/source/sspi"
gitea_context "gitea.dev/services/context"
)
@@ -171,6 +173,8 @@ func (s *SSPI) newUser(ctx context.Context, username string, cfg *sspi.Source) (
return nil, err
}
audit.RecordAs(ctx, user_model.NewAuthenticationSourceUser(), audit_model.UserCreate, user)
return user, nil
}