Files
gitea/services/auth/signin.go
T
3bec08f998 feat: manage bot accounts from the admin UI, API and CLI (#38966)
Adds first-class bot accounts (`UserTypeBot`): local, password-less
users for automation that authenticate only with access tokens.

1. Admin UI: create bots, filter users by type, manage a bot's access
tokens, convert between user and bot
2. API: `POST /admin/users/{username}/convert-type`, and user objects
gain a GitHub-compatible `type` (`User`, `Organization`, `Bot`)
3. CLI: `gitea admin user change-type`, `--user-type` accepts `User` or
`Bot` case-insensitively
4. Converting keeps the password, 2FA, OAuth2 grants and access tokens,
and since sign-in rejects bots, converting back restores the account.
Only local, non-admin accounts can be converted, and conversions are
audited
5. Session, reverse proxy, SSPI, external source and password reset
sign-in reject non-individual users, so a bot never gets an interactive
session
6. Bots receive no notifications or emails

Co-authored-by: Nicolas <bircni@icloud.com>
Co-authored-by: joestump <joe@joestump.net>
Co-authored-by: Joe Stump <joe@stu.mp>
Co-authored-by: silverwind <me@silverwind.io>
Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
2026-09-18 12:43:36 +00:00

148 lines
3.9 KiB
Go

// Copyright 2021 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package auth
import (
"context"
"strings"
"gitea.dev/models/auth"
"gitea.dev/models/db"
user_model "gitea.dev/models/user"
"gitea.dev/modules/log"
"gitea.dev/modules/optional"
"gitea.dev/services/auth/source/oauth2"
"gitea.dev/services/auth/source/smtp"
_ "gitea.dev/services/auth/source/db" // register the sources (and below)
_ "gitea.dev/services/auth/source/ldap" // register the ldap source
_ "gitea.dev/services/auth/source/pam" // register the pam source
_ "gitea.dev/services/auth/source/sspi" // register the sspi source
"xorm.io/builder"
)
// UserSignIn validates user name and password.
func UserSignIn(ctx context.Context, username, password string) (*user_model.User, *auth.Source, error) {
var user *user_model.User
isEmail := false
if strings.Contains(username, "@") {
isEmail = true
// check same email
emailAddress, has, err := db.Get[user_model.EmailAddress](ctx, builder.Eq{"lower_email": strings.ToLower(strings.TrimSpace(username))})
if err != nil {
return nil, nil, err
}
if has {
if !emailAddress.IsActivated {
return nil, nil, user_model.ErrEmailAddressNotExist{
Email: username,
}
}
user = &user_model.User{ID: emailAddress.UID}
}
} else {
trimmedUsername := strings.TrimSpace(username)
if len(trimmedUsername) == 0 {
return nil, nil, user_model.ErrUserNotExist{Name: username}
}
user = &user_model.User{LowerName: strings.ToLower(trimmedUsername)}
}
if user != nil {
var hasUser bool
var err error
if user.ID > 0 {
user, err = user_model.GetUserByID(ctx, user.ID)
if err != nil && !user_model.IsErrUserNotExist(err) {
return nil, nil, err
}
if user != nil && user.Type != user_model.UserTypeIndividual {
return nil, nil, user_model.ErrUserNotExist{Name: username}
}
hasUser = user != nil
} else if user.LowerName != "" {
user, err = user_model.GetIndividualUserByName(ctx, user.LowerName)
if err != nil && !user_model.IsErrUserNotExist(err) {
return nil, nil, err
}
hasUser = user != nil
}
if hasUser {
source, err := auth.GetSourceByID(ctx, user.LoginSource)
if err != nil {
return nil, nil, err
}
if !source.IsActive {
return nil, nil, oauth2.ErrAuthSourceNotActivated
}
authenticator, ok := source.Cfg.(PasswordAuthenticator)
if !ok {
return nil, nil, smtp.ErrUnsupportedLoginType
}
user, err := authenticator.Authenticate(ctx, user, user.LoginName, password)
if err != nil {
return nil, nil, err
}
// WARN: DON'T check user.IsActive, that will be checked on reqSign so that
// user could be hint to resend confirm email.
if user.ProhibitLogin {
return nil, nil, user_model.ErrUserProhibitLogin{UID: user.ID, Name: user.Name}
}
return user, source, nil
}
}
sources, err := db.Find[auth.Source](ctx, auth.FindSourcesOptions{
IsActive: optional.Some(true),
})
if err != nil {
return nil, nil, err
}
for _, source := range sources {
if !source.IsActive {
// don't try to authenticate non-active sources
continue
}
authenticator, ok := source.Cfg.(PasswordAuthenticator)
if !ok {
continue
}
authUser, err := authenticator.Authenticate(ctx, nil, username, password)
if err == nil {
switch {
case !authUser.IsIndividual():
err = user_model.ErrUserNotExist{Name: username}
case authUser.ProhibitLogin:
err = user_model.ErrUserProhibitLogin{UID: authUser.ID, Name: authUser.Name}
default:
return authUser, source, nil
}
}
if user_model.IsErrUserNotExist(err) {
log.Debug("Failed to login '%s' via '%s': %v", username, source.Name, err)
} else {
log.Warn("Failed to login '%s' via '%s': %v", username, source.Name, err)
}
}
if isEmail {
return nil, nil, user_model.ErrEmailAddressNotExist{Email: username}
}
return nil, nil, user_model.ErrUserNotExist{Name: username}
}