mirror of
https://github.com/go-gitea/gitea.git
synced 2026-09-20 19:53:41 +09:00
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>
This commit is contained in:
co-authored by
Nicolas
joestump
Joe Stump
silverwind
Lunny Xiao
parent
db7dbd5a6b
commit
3bec08f998
@@ -11,10 +11,10 @@ import (
|
||||
audit_model "gitea.dev/models/audit"
|
||||
auth_model "gitea.dev/models/auth"
|
||||
"gitea.dev/models/db"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/templates"
|
||||
"gitea.dev/modules/util"
|
||||
"gitea.dev/modules/web"
|
||||
"gitea.dev/services/audit"
|
||||
"gitea.dev/services/context"
|
||||
"gitea.dev/services/forms"
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
|
||||
const (
|
||||
tplSettingsApplications templates.TplName = "user/settings/applications"
|
||||
tplAccessTokens templates.TplName = "shared/user/access_tokens"
|
||||
)
|
||||
|
||||
// Applications render manage access token page
|
||||
@@ -34,11 +35,40 @@ func Applications(ctx *context.Context) {
|
||||
ctx.HTML(http.StatusOK, tplSettingsApplications)
|
||||
}
|
||||
|
||||
// ApplicationsPost response for add user's access token
|
||||
func ApplicationsPost(ctx *context.Context) {
|
||||
form := web.GetForm[*forms.NewAccessTokenForm](ctx)
|
||||
ctx.Data["Title"] = ctx.Tr("settings_title")
|
||||
ctx.Data["PageIsSettingsApplications"] = true
|
||||
type AccessTokensPanel struct {
|
||||
Tokens []*auth_model.AccessToken
|
||||
ScopeCategories []string
|
||||
ScopePublicOnly auth_model.AccessTokenScope
|
||||
Link string
|
||||
IsBot bool
|
||||
NewTokenValue string
|
||||
}
|
||||
|
||||
func NewAccessTokensPanel(ctx *context.Context, owner *user_model.User, link string) *AccessTokensPanel {
|
||||
tokens, err := db.Find[auth_model.AccessToken](ctx, auth_model.ListAccessTokensOptions{UserID: owner.ID})
|
||||
if err != nil {
|
||||
ctx.ServerError("ListAccessTokens", err)
|
||||
return nil
|
||||
}
|
||||
panel := &AccessTokensPanel{
|
||||
Tokens: tokens,
|
||||
ScopeCategories: auth_model.GetAccessTokenCategories(),
|
||||
ScopePublicOnly: auth_model.AccessTokenScopePublicOnly,
|
||||
Link: link,
|
||||
IsBot: owner.IsTypeBot(),
|
||||
}
|
||||
if !owner.IsAdmin {
|
||||
panel.ScopeCategories = util.SliceRemoveAll(panel.ScopeCategories, "admin")
|
||||
}
|
||||
return panel
|
||||
}
|
||||
|
||||
// CreateAccessToken handles the panel's create form, which posts to the panel link
|
||||
func CreateAccessToken(ctx *context.Context, owner *user_model.User) {
|
||||
form := context.GetFetchActionForm[*forms.NewAccessTokenForm](ctx)
|
||||
if form == nil {
|
||||
return
|
||||
}
|
||||
|
||||
_ = ctx.Req.ParseForm()
|
||||
var scopeNames []string
|
||||
@@ -55,17 +85,12 @@ func ApplicationsPost(ctx *context.Context) {
|
||||
return
|
||||
}
|
||||
if !scope.HasPermissionScope() {
|
||||
ctx.Flash.Error(ctx.Tr("settings.at_least_one_permission"), true)
|
||||
}
|
||||
|
||||
if ctx.HasError() {
|
||||
loadApplicationsData(ctx)
|
||||
ctx.HTML(http.StatusOK, tplSettingsApplications)
|
||||
ctx.JSONError(ctx.Tr("settings.at_least_one_permission"))
|
||||
return
|
||||
}
|
||||
|
||||
t := &auth_model.AccessToken{
|
||||
UID: ctx.Doer.ID,
|
||||
UID: owner.ID,
|
||||
Name: form.Name,
|
||||
Scope: scope,
|
||||
}
|
||||
@@ -76,8 +101,7 @@ func ApplicationsPost(ctx *context.Context) {
|
||||
return
|
||||
}
|
||||
if exist {
|
||||
ctx.Flash.Error(ctx.Tr("settings.generate_token_name_duplicate", t.Name))
|
||||
ctx.Redirect(setting.AppSubURL + "/user/settings/applications")
|
||||
ctx.JSONErrorWithField(ctx.Tr("settings.generate_token_name_duplicate", t.Name), "name")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -106,28 +130,41 @@ func ApplicationsPost(ctx *context.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
audit.Record(ctx, audit_model.UserAccessTokenAdd, ctx.Doer, "token", t.Name, "token_scope", t.Scope)
|
||||
audit.Record(ctx, audit_model.UserAccessTokenAdd, owner, "token", t.Name, "token_scope", t.Scope)
|
||||
|
||||
ctx.Flash.Success(ctx.Tr("settings.generate_token_success"))
|
||||
ctx.Flash.Info(t.Token)
|
||||
panel := NewAccessTokensPanel(ctx, owner, ctx.Link)
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
panel.NewTokenValue = t.Token
|
||||
if err := ctx.Render.HTML(ctx.Resp, http.StatusOK, tplAccessTokens, panel, ctx.TemplateContext); err != nil {
|
||||
ctx.ServerError("Render", err)
|
||||
}
|
||||
}
|
||||
|
||||
ctx.Redirect(setting.AppSubURL + "/user/settings/applications")
|
||||
// ApplicationsPost response for add user's access token
|
||||
func ApplicationsPost(ctx *context.Context) {
|
||||
CreateAccessToken(ctx, ctx.Doer)
|
||||
}
|
||||
|
||||
// DeleteApplication response for delete user access token
|
||||
func DeleteApplication(ctx *context.Context) {
|
||||
t, err := auth_model.GetAccessTokenByID(ctx, ctx.FormInt64("id"), ctx.Doer.ID)
|
||||
DeleteAccessToken(ctx, ctx.Doer)
|
||||
}
|
||||
|
||||
func DeleteAccessToken(ctx *context.Context, owner *user_model.User) {
|
||||
t, err := auth_model.GetAccessTokenByID(ctx, ctx.FormInt64("id"), owner.ID)
|
||||
if err != nil {
|
||||
ctx.Flash.Error("GetAccessTokenByID: " + err.Error())
|
||||
} else if err := auth_model.DeleteAccessTokenByID(ctx, t.ID, ctx.Doer.ID); err != nil {
|
||||
} else if err := auth_model.DeleteAccessTokenByID(ctx, t.ID, owner.ID); err != nil {
|
||||
ctx.Flash.Error("DeleteAccessTokenByID: " + err.Error())
|
||||
} else {
|
||||
audit.Record(ctx, audit_model.UserAccessTokenRemove, ctx.Doer, "token", t.Name)
|
||||
audit.Record(ctx, audit_model.UserAccessTokenRemove, owner, "token", t.Name)
|
||||
|
||||
ctx.Flash.Success(ctx.Tr("settings.delete_token_success"))
|
||||
}
|
||||
|
||||
ctx.JSONRedirect(setting.AppSubURL + "/user/settings/applications")
|
||||
ctx.JSONRedirect("")
|
||||
}
|
||||
|
||||
// RegenerateAccessToken response for regenerating a user's access token
|
||||
@@ -143,23 +180,14 @@ func RegenerateAccessToken(ctx *context.Context) {
|
||||
}
|
||||
|
||||
func loadApplicationsData(ctx *context.Context) {
|
||||
ctx.Data["AccessTokenScopePublicOnly"] = auth_model.AccessTokenScopePublicOnly
|
||||
tokens, err := db.Find[auth_model.AccessToken](ctx, auth_model.ListAccessTokensOptions{UserID: ctx.Doer.ID})
|
||||
if err != nil {
|
||||
ctx.ServerError("ListAccessTokens", err)
|
||||
ctx.Data["AccessTokens"] = NewAccessTokensPanel(ctx, ctx.Doer, ctx.Link)
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
ctx.Data["Tokens"] = tokens
|
||||
ctx.Data["EnableOAuth2"] = setting.OAuth2.Enabled
|
||||
|
||||
// Handle specific ordered token categories for admin or non-admin users
|
||||
tokenCategoryNames := auth_model.GetAccessTokenCategories()
|
||||
if !ctx.Doer.IsAdmin {
|
||||
tokenCategoryNames = util.SliceRemoveAll(tokenCategoryNames, "admin")
|
||||
}
|
||||
ctx.Data["TokenCategories"] = tokenCategoryNames
|
||||
|
||||
if setting.OAuth2.Enabled {
|
||||
var err error
|
||||
ctx.Data["Applications"], err = db.Find[auth_model.OAuth2Application](ctx, auth_model.FindOAuth2ApplicationsOptions{
|
||||
OwnerID: ctx.Doer.ID,
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user