feat: Global 2FA enforcement (#8753)

resolves #8549

This PR add a config to enforce 2FA for the whole Forgejo instance. It can be configured to `none`, `admin` or `all`.
A user who is required to enable 2FA is like a disabled user. He can only see the `/user/settings/security`-Page to enable 2FA, this should be similar to a user which needs to change his password. Also api and git-commands are not allowed.

## Checklist

The [contributor guide](https://forgejo.org/docs/next/contributor/) contains information that will be helpful to first time contributors. There also are a few [conditions for merging Pull Requests in Forgejo repositories](https://codeberg.org/forgejo/governance/src/branch/main/PullRequestsAgreement.md). You are also welcome to join the [Forgejo development chatroom](https://matrix.to/#/#forgejo-development:matrix.org).

### Tests

- I added test coverage for Go changes...
  - [x] in their respective `*_test.go` for unit tests.
  - [x] in the `tests/integration` directory if it involves interactions with a live Forgejo server.
- I added test coverage for JavaScript changes...
  - [ ] in `web_src/js/*.test.js` if it can be unit tested.
  - [ ] in `tests/e2e/*.test.e2e.js` if it requires interactions with a live Forgejo server (see also the [developer guide for JavaScript testing](https://codeberg.org/forgejo/forgejo/src/branch/forgejo/tests/e2e/README.md#end-to-end-tests)).

### Documentation

- [ ] I created a pull request [to the documentation](https://codeberg.org/forgejo/docs) to explain to Forgejo users how to use this change.
- [ ] I did not document these changes and I do not expect someone else to do it.

I will do it, if the general idea of this PR is a good feature.

### Release notes

- [ ] I do not want this change to show in the release notes.
- [x] I want the title to show in the release notes with a link to this pull request.
- [ ] I want the content of the `release-notes/<pull request number>.md` to be be used for the release notes instead of the title.

<!--start release-notes-assistant-->

## Release notes
<!--URL:https://codeberg.org/forgejo/forgejo-->
- Security features
  - [PR](https://codeberg.org/forgejo/forgejo/pulls/8753): <!--number 8753 --><!--line 0 --><!--description R2xvYmFsIDJGQSBlbmZvcmNlbWVudA==-->Global 2FA enforcement<!--description-->
<!--end release-notes-assistant-->

Co-authored-by: 0ko <0ko@noreply.codeberg.org>
Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/8753
Reviewed-by: 0ko <0ko@noreply.codeberg.org>
Reviewed-by: Ellen Εμιλία Άννα Zscheile <fogti@noreply.codeberg.org>
Reviewed-by: Gusted <gusted@noreply.codeberg.org>
Co-authored-by: zokki <zokki.softwareschmiede@gmail.com>
Co-committed-by: zokki <zokki.softwareschmiede@gmail.com>
This commit is contained in:
zokki 2025-08-15 10:56:45 +02:00 committed by Earl Warren
commit d6838462b8
29 changed files with 1179 additions and 62 deletions

View file

@ -449,6 +449,9 @@ INTERNAL_TOKEN =
;; How long to remember that a user is logged in before requiring relogin (in days) ;; How long to remember that a user is logged in before requiring relogin (in days)
;LOGIN_REMEMBER_DAYS = 31 ;LOGIN_REMEMBER_DAYS = 31
;; ;;
;; Require 2FA globally for none|all|admin.
;GLOBAL_TWO_FACTOR_REQUIREMENT = none
;;
;; Name of cookie used to store authentication information. ;; Name of cookie used to store authentication information.
;COOKIE_REMEMBER_NAME = gitea_incredible ;COOKIE_REMEMBER_NAME = gitea_incredible
;; ;;

View file

@ -234,6 +234,33 @@ func GetAllAdmins(ctx context.Context) ([]*User, error) {
return users, db.GetEngine(ctx).OrderBy("id").Where("type = ?", UserTypeIndividual).And("is_admin = ?", true).Find(&users) return users, db.GetEngine(ctx).OrderBy("id").Where("type = ?", UserTypeIndividual).And("is_admin = ?", true).Find(&users)
} }
// MustHaveTwoFactor returns true if the user is a individual and requires 2fa
func (u *User) MustHaveTwoFactor() bool {
if !u.IsIndividual() || setting.GlobalTwoFactorRequirement.IsNone() {
return false
}
return setting.GlobalTwoFactorRequirement.IsAll() || (u.IsAdmin && setting.GlobalTwoFactorRequirement.IsAdmin())
}
// IsAccessAllowed determines whether the user is permitted to log in based on
// their activation status, login prohibition, 2FA requirement and 2FA enrollment status.
func (u *User) IsAccessAllowed(ctx context.Context) bool {
if !u.IsActive || u.ProhibitLogin {
return false
}
if !u.MustHaveTwoFactor() {
return true
}
hasTwoFactor, err := auth.HasTwoFactorByUID(ctx, u.ID)
if err != nil {
log.Error("Error getting 2fa: %s", err)
return false
}
return hasTwoFactor
}
// IsLocal returns true if user login type is LoginPlain. // IsLocal returns true if user login type is LoginPlain.
func (u *User) IsLocal() bool { func (u *User) IsLocal() bool {
return u.LoginType <= auth.Plain return u.LoginType <= auth.Plain

View file

@ -637,6 +637,145 @@ func TestGetAllAdmins(t *testing.T) {
assert.Equal(t, int64(1), admins[0].ID) assert.Equal(t, int64(1), admins[0].ID)
} }
func TestMustHaveTwoFactor(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
adminUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
normalUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4})
org := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 17})
restrictedUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 29})
ghostUser := user_model.NewGhostUser()
t.Run("NoneTwoFactorRequirement", func(t *testing.T) {
// this should be the default, so don't have to set the variable
assert.False(t, adminUser.MustHaveTwoFactor())
assert.False(t, normalUser.MustHaveTwoFactor())
assert.False(t, restrictedUser.MustHaveTwoFactor())
assert.False(t, org.MustHaveTwoFactor())
assert.False(t, ghostUser.MustHaveTwoFactor())
})
t.Run("AllTwoFactorRequirement", func(t *testing.T) {
defer test.MockVariableValue(&setting.GlobalTwoFactorRequirement, setting.AllTwoFactorRequirement)()
assert.True(t, adminUser.MustHaveTwoFactor())
assert.True(t, normalUser.MustHaveTwoFactor())
assert.True(t, restrictedUser.MustHaveTwoFactor())
assert.False(t, org.MustHaveTwoFactor())
assert.True(t, ghostUser.MustHaveTwoFactor())
})
t.Run("AdminTwoFactorRequirement", func(t *testing.T) {
defer test.MockVariableValue(&setting.GlobalTwoFactorRequirement, setting.AdminTwoFactorRequirement)()
assert.True(t, adminUser.MustHaveTwoFactor())
assert.False(t, normalUser.MustHaveTwoFactor())
assert.False(t, restrictedUser.MustHaveTwoFactor())
assert.False(t, org.MustHaveTwoFactor())
assert.False(t, ghostUser.MustHaveTwoFactor())
})
}
func TestIsAccessAllowed(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
runTest := func(t *testing.T, user *user_model.User, useTOTP, accessAllowed bool) {
t.Helper()
if useTOTP {
unittest.AssertSuccessfulInsert(t, &auth.TwoFactor{UID: user.ID})
defer unittest.AssertSuccessfulDelete(t, &auth.TwoFactor{UID: user.ID})
}
assert.Equal(t, accessAllowed, user.IsAccessAllowed(t.Context()))
}
adminUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
normalUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4})
inactiveUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 9})
org := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 17})
restrictedUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 29})
prohibitLoginUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 37})
ghostUser := user_model.NewGhostUser()
// users with enabled WebAuthn
normalWebAuthnUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 32})
t.Run("NoneTwoFactorRequirement", func(t *testing.T) {
// this should be the default, so don't have to set the variable
t.Run("no 2fa", func(t *testing.T) {
runTest(t, adminUser, false, true)
runTest(t, normalUser, false, true)
runTest(t, inactiveUser, false, false)
runTest(t, org, false, true)
runTest(t, restrictedUser, false, true)
runTest(t, prohibitLoginUser, false, false)
runTest(t, ghostUser, false, false)
})
t.Run("enabled 2fa", func(t *testing.T) {
runTest(t, normalWebAuthnUser, false, true)
runTest(t, adminUser, true, true)
runTest(t, normalUser, true, true)
runTest(t, inactiveUser, true, false)
runTest(t, org, true, true)
runTest(t, restrictedUser, true, true)
runTest(t, prohibitLoginUser, true, false)
})
})
t.Run("AllTwoFactorRequirement", func(t *testing.T) {
defer test.MockVariableValue(&setting.GlobalTwoFactorRequirement, setting.AllTwoFactorRequirement)()
t.Run("no 2fa", func(t *testing.T) {
runTest(t, adminUser, false, false)
runTest(t, normalUser, false, false)
runTest(t, inactiveUser, false, false)
runTest(t, org, false, true)
runTest(t, restrictedUser, false, false)
runTest(t, prohibitLoginUser, false, false)
runTest(t, ghostUser, false, false)
})
t.Run("enabled 2fa", func(t *testing.T) {
runTest(t, normalWebAuthnUser, false, true)
runTest(t, adminUser, true, true)
runTest(t, normalUser, true, true)
runTest(t, inactiveUser, true, false)
runTest(t, org, true, true)
runTest(t, restrictedUser, true, true)
runTest(t, prohibitLoginUser, true, false)
})
})
t.Run("AdminTwoFactorRequirement", func(t *testing.T) {
defer test.MockVariableValue(&setting.GlobalTwoFactorRequirement, setting.AdminTwoFactorRequirement)()
t.Run("no 2fa", func(t *testing.T) {
runTest(t, adminUser, false, false)
runTest(t, normalUser, false, true)
runTest(t, inactiveUser, false, false)
runTest(t, org, false, true)
runTest(t, restrictedUser, false, true)
runTest(t, prohibitLoginUser, false, false)
runTest(t, ghostUser, false, false)
})
t.Run("enabled 2fa", func(t *testing.T) {
runTest(t, normalWebAuthnUser, false, true)
runTest(t, adminUser, true, true)
runTest(t, normalUser, true, true)
runTest(t, inactiveUser, true, false)
runTest(t, org, true, true)
runTest(t, restrictedUser, true, true)
runTest(t, prohibitLoginUser, true, false)
})
})
}
func Test_ValidateUser(t *testing.T) { func Test_ValidateUser(t *testing.T) {
defer test.MockVariableValue(&setting.Service.AllowedUserVisibilityModesSlice, []bool{true, false, true})() defer test.MockVariableValue(&setting.Service.AllowedUserVisibilityModesSlice, []bool{true, false, true})()

View file

@ -20,6 +20,7 @@ var (
SecretKey string SecretKey string
InternalToken string // internal access token InternalToken string // internal access token
LogInRememberDays int LogInRememberDays int
GlobalTwoFactorRequirement TwoFactorRequirementType
CookieRememberName string CookieRememberName string
ReverseProxyAuthUser string ReverseProxyAuthUser string
ReverseProxyAuthEmail string ReverseProxyAuthEmail string
@ -113,6 +114,8 @@ func loadSecurityFrom(rootCfg ConfigProvider) {
} }
keying.Init([]byte(SecretKey)) keying.Init([]byte(SecretKey))
GlobalTwoFactorRequirement = NewTwoFactorRequirementType(sec.Key("GLOBAL_TWO_FACTOR_REQUIREMENT").String())
CookieRememberName = sec.Key("COOKIE_REMEMBER_NAME").MustString("gitea_incredible") CookieRememberName = sec.Key("COOKIE_REMEMBER_NAME").MustString("gitea_incredible")
ReverseProxyAuthUser = sec.Key("REVERSE_PROXY_AUTHENTICATION_USER").MustString("X-WEBAUTH-USER") ReverseProxyAuthUser = sec.Key("REVERSE_PROXY_AUTHENTICATION_USER").MustString("X-WEBAUTH-USER")
@ -171,3 +174,39 @@ func loadSecurityFrom(rootCfg ConfigProvider) {
log.Warn("Enabling Query API Auth tokens is not recommended. DISABLE_QUERY_AUTH_TOKEN will be removed in Forgejo v13.0.0.") log.Warn("Enabling Query API Auth tokens is not recommended. DISABLE_QUERY_AUTH_TOKEN will be removed in Forgejo v13.0.0.")
} }
} }
type TwoFactorRequirementType string
// llu:TrKeysSuffix admin.config.global_2fa_requirement.
const (
NoneTwoFactorRequirement TwoFactorRequirementType = "none"
AllTwoFactorRequirement TwoFactorRequirementType = "all"
AdminTwoFactorRequirement TwoFactorRequirementType = "admin"
)
func NewTwoFactorRequirementType(twoFactorRequirement string) TwoFactorRequirementType {
switch twoFactorRequirement {
case AllTwoFactorRequirement.String():
return AllTwoFactorRequirement
case AdminTwoFactorRequirement.String():
return AdminTwoFactorRequirement
default:
return NoneTwoFactorRequirement
}
}
func (r TwoFactorRequirementType) String() string {
return string(r)
}
func (r TwoFactorRequirementType) IsNone() bool {
return r == NoneTwoFactorRequirement
}
func (r TwoFactorRequirementType) IsAll() bool {
return r == AllTwoFactorRequirement
}
func (r TwoFactorRequirementType) IsAdmin() bool {
return r == AdminTwoFactorRequirement
}

View file

@ -112,7 +112,17 @@
"admin.auths.allow_username_change.description": "Allow users to change their username in the profile settings", "admin.auths.allow_username_change.description": "Allow users to change their username in the profile settings",
"admin.dashboard.cleanup_offline_runners": "Cleanup offline runners", "admin.dashboard.cleanup_offline_runners": "Cleanup offline runners",
"admin.dashboard.remove_resolved_reports": "Remove resolved reports", "admin.dashboard.remove_resolved_reports": "Remove resolved reports",
"admin.config.security": "Security configuration",
"admin.config.global_2fa_requirement.title": "Global two-factor requirement",
"admin.config.global_2fa_requirement.none": "No",
"admin.config.global_2fa_requirement.all": "All users",
"admin.config.global_2fa_requirement.admin": "Administrators",
"settings.visibility.description": "Profile visibility affects others' ability to access your non-private repositories. <a href=\"%s\" target=\"_blank\">Learn more</a>.", "settings.visibility.description": "Profile visibility affects others' ability to access your non-private repositories. <a href=\"%s\" target=\"_blank\">Learn more</a>.",
"settings.twofa_unroll_unavailable": "Two-factor authentication is required for your account and cannot be disabled.",
"settings.twofa_reenroll": "Re-enroll two-factor authentication",
"settings.twofa_reenroll.description": "Re-enroll your two-factor authentication",
"settings.must_enable_2fa": "This Forgejo instance requires users to enable two-factor authentication before they can access their accounts.",
"error.must_enable_2fa": "This Forgejo instance requires users to enable two-factor authentication before they can access their accounts. Enable it at: %s",
"avatar.constraints_hint": "Custom avatar may not exceed %[1]s in size or be larger than %[2]dx%[3]d pixels", "avatar.constraints_hint": "Custom avatar may not exceed %[1]s in size or be larger than %[2]dx%[3]d pixels",
"og.repo.summary_card.alt_description": "Summary card of repository %[1]s, described as: %[2]s", "og.repo.summary_card.alt_description": "Summary card of repository %[1]s, described as: %[2]s",
"repo.commit.load_tags_failed": "Load tags failed because of internal error", "repo.commit.load_tags_failed": "Load tags failed because of internal error",

View file

@ -4,8 +4,10 @@
package shared package shared
import ( import (
"fmt"
"net/http" "net/http"
auth_model "forgejo.org/models/auth"
"forgejo.org/modules/log" "forgejo.org/modules/log"
"forgejo.org/modules/setting" "forgejo.org/modules/setting"
"forgejo.org/routers/common" "forgejo.org/routers/common"
@ -92,6 +94,25 @@ func verifyAuthWithOptions(options *common.VerifyOptions) func(ctx *context.APIC
}) })
return return
} }
if ctx.Doer.MustHaveTwoFactor() {
hasTwoFactor, err := auth_model.HasTwoFactorByUID(ctx, ctx.Doer.ID)
if err != nil {
ctx.Data["Title"] = ctx.Tr("auth.prohibit_login")
log.Error("Error getting 2fa: %s", err)
ctx.JSON(http.StatusInternalServerError, map[string]string{
"message": fmt.Sprintf("Error getting 2fa: %s", err),
})
return
}
if !hasTwoFactor {
ctx.Data["Title"] = ctx.Tr("auth.prohibit_login")
ctx.JSON(http.StatusForbidden, map[string]string{
"message": ctx.Locale.TrString("error.must_enable_2fa", fmt.Sprintf("%suser/settings/security", setting.AppURL)),
})
return
}
}
} }
// Redirect to dashboard if user tries to visit any non-login page. // Redirect to dashboard if user tries to visit any non-login page.

View file

@ -9,6 +9,7 @@ import (
"strings" "strings"
asymkey_model "forgejo.org/models/asymkey" asymkey_model "forgejo.org/models/asymkey"
"forgejo.org/models/auth"
"forgejo.org/models/perm" "forgejo.org/models/perm"
access_model "forgejo.org/models/perm/access" access_model "forgejo.org/models/perm/access"
repo_model "forgejo.org/models/repo" repo_model "forgejo.org/models/repo"
@ -22,6 +23,27 @@ import (
wiki_service "forgejo.org/services/wiki" wiki_service "forgejo.org/services/wiki"
) )
func checkTwoFactor(ctx *context.PrivateContext, user *user_model.User) {
if !user.MustHaveTwoFactor() {
return
}
hasTwoFactor, err := auth.HasTwoFactorByUID(ctx, user.ID)
if err != nil {
log.Error("Error getting 2fa: %s", err)
ctx.JSON(http.StatusInternalServerError, private.Response{
Err: fmt.Sprintf("Error getting 2fa: %s", err),
})
return
}
if !hasTwoFactor {
ctx.JSON(http.StatusForbidden, private.Response{
UserMsg: ctx.Locale.TrString("error.must_enable_2fa", fmt.Sprintf("%suser/settings/security", setting.AppURL)),
})
return
}
}
// ServNoCommand returns information about the provided keyid // ServNoCommand returns information about the provided keyid
func ServNoCommand(ctx *context.PrivateContext) { func ServNoCommand(ctx *context.PrivateContext) {
keyID := ctx.ParamsInt64(":keyid") keyID := ctx.ParamsInt64(":keyid")
@ -69,6 +91,12 @@ func ServNoCommand(ctx *context.PrivateContext) {
}) })
return return
} }
checkTwoFactor(ctx, user)
if ctx.Written() {
return
}
results.Owner = user results.Owner = user
} }
ctx.JSON(http.StatusOK, &results) ctx.JSON(http.StatusOK, &results)
@ -266,6 +294,11 @@ func ServCommand(ctx *context.PrivateContext) {
return return
} }
checkTwoFactor(ctx, user)
if ctx.Written() {
return
}
results.UserName = user.Name results.UserName = user.Name
if !user.KeepEmailPrivate { if !user.KeepEmailPrivate {
results.UserEmail = user.Email results.UserEmail = user.Email

View file

@ -128,6 +128,7 @@ func Config(ctx *context.Context) {
ctx.Data["AppBuiltWith"] = setting.AppBuiltWith ctx.Data["AppBuiltWith"] = setting.AppBuiltWith
ctx.Data["Domain"] = setting.Domain ctx.Data["Domain"] = setting.Domain
ctx.Data["OfflineMode"] = setting.OfflineMode ctx.Data["OfflineMode"] = setting.OfflineMode
ctx.Data["GlobalTwoFactorRequirement"] = setting.GlobalTwoFactorRequirement
ctx.Data["RunUser"] = setting.RunUser ctx.Data["RunUser"] = setting.RunUser
ctx.Data["RunMode"] = util.ToTitleCase(setting.RunMode) ctx.Data["RunMode"] = util.ToTitleCase(setting.RunMode)
ctx.Data["GitVersion"] = git.VersionInfo() ctx.Data["GitVersion"] = git.VersionInfo()

View file

@ -264,7 +264,7 @@ func ResetPasswdPost(ctx *context.Context) {
func MustChangePassword(ctx *context.Context) { func MustChangePassword(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("auth.must_change_password") ctx.Data["Title"] = ctx.Tr("auth.must_change_password")
ctx.Data["ChangePasscodeLink"] = setting.AppSubURL + "/user/settings/change_password" ctx.Data["ChangePasscodeLink"] = setting.AppSubURL + "/user/settings/change_password"
ctx.Data["MustChangePassword"] = true ctx.Data["HideNavbarLinks"] = true
ctx.HTML(http.StatusOK, tplMustChangePassword) ctx.HTML(http.StatusOK, tplMustChangePassword)
} }

View file

@ -9,6 +9,7 @@ import (
"net/http" "net/http"
"strconv" "strconv"
auth_model "forgejo.org/models/auth"
"forgejo.org/models/db" "forgejo.org/models/db"
repo_model "forgejo.org/models/repo" repo_model "forgejo.org/models/repo"
user_model "forgejo.org/models/user" user_model "forgejo.org/models/user"
@ -35,18 +36,37 @@ func Home(ctx *context.Context) {
if !ctx.Doer.IsActive && setting.Service.RegisterEmailConfirm { if !ctx.Doer.IsActive && setting.Service.RegisterEmailConfirm {
ctx.Data["Title"] = ctx.Tr("auth.active_your_account") ctx.Data["Title"] = ctx.Tr("auth.active_your_account")
ctx.HTML(http.StatusOK, auth.TplActivate) ctx.HTML(http.StatusOK, auth.TplActivate)
} else if !ctx.Doer.IsActive || ctx.Doer.ProhibitLogin { return
}
if !ctx.Doer.IsActive || ctx.Doer.ProhibitLogin {
log.Info("Failed authentication attempt for %s from %s", ctx.Doer.Name, ctx.RemoteAddr()) log.Info("Failed authentication attempt for %s from %s", ctx.Doer.Name, ctx.RemoteAddr())
ctx.Data["Title"] = ctx.Tr("auth.prohibit_login") ctx.Data["Title"] = ctx.Tr("auth.prohibit_login")
ctx.HTML(http.StatusOK, "user/auth/prohibit_login") ctx.HTML(http.StatusOK, "user/auth/prohibit_login")
} else if ctx.Doer.MustChangePassword { return
}
if ctx.Doer.MustChangePassword {
ctx.Data["Title"] = ctx.Tr("auth.must_change_password") ctx.Data["Title"] = ctx.Tr("auth.must_change_password")
ctx.Data["ChangePasscodeLink"] = setting.AppSubURL + "/user/change_password" ctx.Data["ChangePasscodeLink"] = setting.AppSubURL + "/user/change_password"
middleware.SetRedirectToCookie(ctx.Resp, setting.AppSubURL+ctx.Req.URL.RequestURI()) middleware.SetRedirectToCookie(ctx.Resp, setting.AppSubURL+ctx.Req.URL.RequestURI())
ctx.Redirect(setting.AppSubURL + "/user/settings/change_password") ctx.Redirect(setting.AppSubURL + "/user/settings/change_password")
} else { return
user.Dashboard(ctx)
} }
if ctx.Doer.MustHaveTwoFactor() {
hasTwoFactor, err := auth_model.HasTwoFactorByUID(ctx, ctx.Doer.ID)
if err != nil {
ctx.Data["Title"] = ctx.Tr("auth.prohibit_login")
log.Error("Error getting 2fa: %s", err)
ctx.Error(http.StatusInternalServerError, "HasTwoFactorByUID", err.Error())
return
}
if !hasTwoFactor {
ctx.Data["Title"] = ctx.Tr("auth.prohibit_login")
ctx.Redirect(setting.AppSubURL + "/user/settings/security")
return
}
}
user.Dashboard(ctx)
return return
// Check non-logged users landing page. // Check non-logged users landing page.
} else if setting.LandingPageURL != setting.LandingPageHome { } else if setting.LandingPageURL != setting.LandingPageHome {

View file

@ -56,6 +56,21 @@ func DisableTwoFactor(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("settings") ctx.Data["Title"] = ctx.Tr("settings")
ctx.Data["PageIsSettingsSecurity"] = true ctx.Data["PageIsSettingsSecurity"] = true
if ctx.Doer.MustHaveTwoFactor() {
ctx.NotFound("DisableTwoFactor", nil)
return
}
disableTwoFactor(ctx)
if ctx.Written() {
return
}
ctx.Flash.Success(ctx.Tr("settings.twofa_disabled"))
ctx.Redirect(setting.AppSubURL + "/user/settings/security")
}
func disableTwoFactor(ctx *context.Context) {
t, err := auth.GetTwoFactorByUID(ctx, ctx.Doer.ID) t, err := auth.GetTwoFactorByUID(ctx, ctx.Doer.ID)
if err != nil { if err != nil {
if auth.IsErrTwoFactorNotEnrolled(err) { if auth.IsErrTwoFactorNotEnrolled(err) {
@ -82,9 +97,6 @@ func DisableTwoFactor(ctx *context.Context) {
ctx.ServerError("SendDisabledTOTP", err) ctx.ServerError("SendDisabledTOTP", err)
return return
} }
ctx.Flash.Success(ctx.Tr("settings.twofa_disabled"))
ctx.Redirect(setting.AppSubURL + "/user/settings/security")
} }
func twofaGenerateSecretAndQr(ctx *context.Context) bool { func twofaGenerateSecretAndQr(ctx *context.Context) bool {
@ -172,7 +184,6 @@ func EnrollTwoFactor(ctx *context.Context) {
// EnrollTwoFactorPost handles enrolling the user into 2FA. // EnrollTwoFactorPost handles enrolling the user into 2FA.
func EnrollTwoFactorPost(ctx *context.Context) { func EnrollTwoFactorPost(ctx *context.Context) {
form := web.GetForm(ctx).(*forms.TwoFactorAuthForm)
ctx.Data["Title"] = ctx.Tr("settings") ctx.Data["Title"] = ctx.Tr("settings")
ctx.Data["PageIsSettingsSecurity"] = true ctx.Data["PageIsSettingsSecurity"] = true
@ -188,6 +199,12 @@ func EnrollTwoFactorPost(ctx *context.Context) {
return return
} }
enrollTwoFactor(ctx)
}
func enrollTwoFactor(ctx *context.Context) {
form := web.GetForm(ctx).(*forms.TwoFactorAuthForm)
if ctx.HasError() { if ctx.HasError() {
if !twofaGenerateSecretAndQr(ctx) { if !twofaGenerateSecretAndQr(ctx) {
return return
@ -213,10 +230,10 @@ func EnrollTwoFactorPost(ctx *context.Context) {
return return
} }
t = &auth.TwoFactor{ twoFactor := &auth.TwoFactor{
UID: ctx.Doer.ID, UID: ctx.Doer.ID,
} }
token := t.GenerateScratchToken() token := twoFactor.GenerateScratchToken()
// Now we have to delete the secrets - because if we fail to insert then it's highly likely that they have already been used // Now we have to delete the secrets - because if we fail to insert then it's highly likely that they have already been used
// If we can detect the unique constraint failure below we can move this to after the NewTwoFactor // If we can detect the unique constraint failure below we can move this to after the NewTwoFactor
@ -238,7 +255,7 @@ func EnrollTwoFactorPost(ctx *context.Context) {
return return
} }
if err = auth.NewTwoFactor(ctx, t, secret); err != nil { if err := auth.NewTwoFactor(ctx, twoFactor, secret); err != nil {
// FIXME: We need to handle a unique constraint fail here it's entirely possible that another request has beaten us. // FIXME: We need to handle a unique constraint fail here it's entirely possible that another request has beaten us.
// If there is a unique constraint fail we should just tolerate the error // If there is a unique constraint fail we should just tolerate the error
ctx.ServerError("SettingsTwoFactor: Failed to save two factor", err) ctx.ServerError("SettingsTwoFactor: Failed to save two factor", err)
@ -248,3 +265,41 @@ func EnrollTwoFactorPost(ctx *context.Context) {
ctx.Flash.Success(ctx.Tr("settings.twofa_enrolled", token)) ctx.Flash.Success(ctx.Tr("settings.twofa_enrolled", token))
ctx.Redirect(setting.AppSubURL + "/user/settings/security") ctx.Redirect(setting.AppSubURL + "/user/settings/security")
} }
// ReenrollTwoFactor shows the page where the user can reenroll 2FA.
func ReenrollTwoFactor(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("settings")
ctx.Data["PageIsSettingsSecurity"] = true
ctx.Data["ReenrollTwofa"] = true
_, err := auth.GetTwoFactorByUID(ctx, ctx.Doer.ID)
if auth.IsErrTwoFactorNotEnrolled(err) {
ctx.Flash.Error(ctx.Tr("settings.twofa_not_enrolled"))
ctx.Redirect(setting.AppSubURL + "/user/settings/security")
return
}
if err != nil {
ctx.ServerError("SettingsTwoFactor: GetTwoFactorByUID", err)
return
}
if !twofaGenerateSecretAndQr(ctx) {
return
}
ctx.HTML(http.StatusOK, tplSettingsTwofaEnroll)
}
// ReenrollTwoFactorPost handles reenrolling the user 2FA.
func ReenrollTwoFactorPost(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("settings")
ctx.Data["PageIsSettingsSecurity"] = true
ctx.Data["ReenrollTwofa"] = true
disableTwoFactor(ctx)
if ctx.Written() {
return
}
enrollTwoFactor(ctx)
}

View file

@ -6,9 +6,11 @@ package web
import ( import (
gocontext "context" gocontext "context"
"fmt"
"net/http" "net/http"
"strings" "strings"
auth_model "forgejo.org/models/auth"
"forgejo.org/models/perm" "forgejo.org/models/perm"
quota_model "forgejo.org/models/quota" quota_model "forgejo.org/models/quota"
"forgejo.org/models/unit" "forgejo.org/models/unit"
@ -169,6 +171,19 @@ func verifyAuthWithOptions(options *common.VerifyOptions) func(ctx *context.Cont
ctx.Redirect(setting.AppSubURL + "/") ctx.Redirect(setting.AppSubURL + "/")
return return
} }
if ctx.Doer.MustHaveTwoFactor() && !strings.HasPrefix(ctx.Req.URL.Path, "/user/settings/security") {
hasTwoFactor, err := auth_model.HasTwoFactorByUID(ctx, ctx.Doer.ID)
if err != nil {
log.Error("Error getting 2fa: %s", err)
ctx.Error(http.StatusInternalServerError, "HasTwoFactorByUID", err.Error())
return
}
if !hasTwoFactor {
ctx.Redirect(setting.AppSubURL + "/user/settings/security")
return
}
}
} }
// Redirect to dashboard (or alternate location) if user tries to visit any non-login page. // Redirect to dashboard (or alternate location) if user tries to visit any non-login page.
@ -309,6 +324,20 @@ func registerRoutes(m *web.Route) {
} }
} }
requiredTwoFactor := func(ctx *context.Context) {
if !ctx.Doer.MustHaveTwoFactor() {
return
}
hasTwoFactor, err := auth_model.HasTwoFactorByUID(ctx, ctx.Doer.ID)
if err != nil {
ctx.Error(http.StatusInternalServerError, fmt.Sprintf("Error getting 2fa: %s", err))
return
}
ctx.Data["MustEnableTwoFactor"] = !hasTwoFactor
ctx.Data["HideNavbarLinks"] = !hasTwoFactor
}
openIDSignInEnabled := func(ctx *context.Context) { openIDSignInEnabled := func(ctx *context.Context) {
if !setting.Service.EnableOpenIDSignIn { if !setting.Service.EnableOpenIDSignIn {
ctx.Error(http.StatusForbidden) ctx.Error(http.StatusForbidden)
@ -564,6 +593,8 @@ func registerRoutes(m *web.Route) {
m.Post("/disable", security.DisableTwoFactor) m.Post("/disable", security.DisableTwoFactor)
m.Get("/enroll", security.EnrollTwoFactor) m.Get("/enroll", security.EnrollTwoFactor)
m.Post("/enroll", web.Bind(forms.TwoFactorAuthForm{}), security.EnrollTwoFactorPost) m.Post("/enroll", web.Bind(forms.TwoFactorAuthForm{}), security.EnrollTwoFactorPost)
m.Get("/reenroll", security.ReenrollTwoFactor)
m.Post("/reenroll", web.Bind(forms.TwoFactorAuthForm{}), security.ReenrollTwoFactorPost)
}) })
m.Group("/webauthn", func() { m.Group("/webauthn", func() {
m.Post("/request_register", web.Bind(forms.WebauthnRegistrationForm{}), security.WebAuthnRegister) m.Post("/request_register", web.Bind(forms.WebauthnRegistrationForm{}), security.WebAuthnRegister)
@ -576,7 +607,7 @@ func registerRoutes(m *web.Route) {
m.Post("/toggle_visibility", security.ToggleOpenIDVisibility) m.Post("/toggle_visibility", security.ToggleOpenIDVisibility)
}, openIDSignInEnabled) }, openIDSignInEnabled)
m.Post("/account_link", linkAccountEnabled, security.DeleteAccountLink) m.Post("/account_link", linkAccountEnabled, security.DeleteAccountLink)
}) }, requiredTwoFactor)
m.Group("/applications", func() { m.Group("/applications", func() {
// oauth2 applications // oauth2 applications

View file

@ -17,6 +17,7 @@ import (
"syscall" "syscall"
"time" "time"
"forgejo.org/models/auth"
user_model "forgejo.org/models/user" user_model "forgejo.org/models/user"
"forgejo.org/modules/base" "forgejo.org/modules/base"
"forgejo.org/modules/httplib" "forgejo.org/modules/httplib"
@ -129,6 +130,21 @@ func (ctx *Context) RenderWithErr(msg any, tpl base.TplName, form any) {
ctx.HTML(http.StatusOK, tpl) ctx.HTML(http.StatusOK, tpl)
} }
// validateTwoFactorRequirement sets ctx-data to hide/show ui-elements depending on the GlobalTwoFactorRequirement
func (ctx *Context) validateTwoFactorRequirement() {
if ctx.Doer == nil || !ctx.Doer.MustHaveTwoFactor() {
return
}
hasTwoFactor, err := auth.HasTwoFactorByUID(ctx, ctx.Doer.ID)
if err != nil {
log.ErrorWithSkip(2, "Error getting 2fa: %s", err)
// fallthrough to set the variables
}
ctx.Data["MustEnableTwoFactor"] = !hasTwoFactor
ctx.Data["HideNavbarLinks"] = !hasTwoFactor
}
// NotFound displays a 404 (Not Found) page and prints the given error, if any. // NotFound displays a 404 (Not Found) page and prints the given error, if any.
func (ctx *Context) NotFound(logMsg string, logErr error) { func (ctx *Context) NotFound(logMsg string, logErr error) {
ctx.notFoundInternal(logMsg, logErr) ctx.notFoundInternal(logMsg, logErr)
@ -156,6 +172,7 @@ func (ctx *Context) notFoundInternal(logMsg string, logErr error) {
return return
} }
ctx.validateTwoFactorRequirement()
ctx.Data["IsRepo"] = ctx.Repo.Repository != nil ctx.Data["IsRepo"] = ctx.Repo.Repository != nil
ctx.Data["Title"] = ctx.Locale.TrString("error.not_found.title") ctx.Data["Title"] = ctx.Locale.TrString("error.not_found.title")
ctx.HTML(http.StatusNotFound, tplStatus404) ctx.HTML(http.StatusNotFound, tplStatus404)
@ -181,6 +198,7 @@ func (ctx *Context) serverErrorInternal(logMsg string, logErr error) {
} }
} }
ctx.validateTwoFactorRequirement()
ctx.HTML(http.StatusInternalServerError, tplStatus500) ctx.HTML(http.StatusInternalServerError, tplStatus500)
} }

View file

@ -97,7 +97,7 @@ func determineAccessMode(ctx *Base, pkg *Package, doer *user_model.User) (perm.A
return perm.AccessModeNone, nil return perm.AccessModeNone, nil
} }
if doer != nil && !doer.IsGhost() && (!doer.IsActive || doer.ProhibitLogin) { if doer != nil && !doer.IsGhost() && !doer.IsAccessAllowed(ctx) {
return perm.AccessModeNone, nil return perm.AccessModeNone, nil
} }

View file

@ -51,6 +51,16 @@
</dl> </dl>
</div> </div>
<h4 class="ui top attached header">
{{ctx.Locale.Tr "admin.config.security"}}
</h4>
<div class="ui attached table segment">
<dl class="admin-dl-horizontal">
<dt>{{ctx.Locale.Tr "admin.config.global_2fa_requirement.title"}}</dt>
<dd>{{ctx.Locale.Tr (print "admin.config.global_2fa_requirement." .GlobalTwoFactorRequirement)}}</dd>
</dl>
</div>
<h4 class="ui top attached header"> <h4 class="ui top attached header">
{{ctx.Locale.Tr "admin.config.ssh_config"}} {{ctx.Locale.Tr "admin.config.ssh_config"}}
</h4> </h4>

View file

@ -24,7 +24,7 @@
</div> </div>
<!-- navbar links non-mobile --> <!-- navbar links non-mobile -->
{{if and .IsSigned .MustChangePassword}} {{if and .IsSigned .HideNavbarLinks}}
{{/* No links */}} {{/* No links */}}
{{else if .IsSigned}} {{else if .IsSigned}}
{{if not .UnitIssuesGlobalDisabled}} {{if not .UnitIssuesGlobalDisabled}}
@ -54,7 +54,7 @@
<!-- the full dropdown menus --> <!-- the full dropdown menus -->
<div class="navbar-right ui secondary menu"> <div class="navbar-right ui secondary menu">
{{if and .IsSigned .MustChangePassword}} {{if and .IsSigned .HideNavbarLinks}}
<div class="ui dropdown jump item" data-tooltip-content="{{ctx.Locale.Tr "user_profile_and_more"}}"> <div class="ui dropdown jump item" data-tooltip-content="{{ctx.Locale.Tr "user_profile_and_more"}}">
<span class="text tw-flex tw-items-center"> <span class="text tw-flex tw-items-center">
{{ctx.AvatarUtils.Avatar .SignedUser 24 "tw-mr-1"}} {{ctx.AvatarUtils.Avatar .SignedUser 24 "tw-mr-1"}}

View file

@ -1,3 +1,4 @@
{{if not .HideNavbarLinks}}
<div class="flex-container-nav"> <div class="flex-container-nav">
<div class="ui fluid vertical menu"> <div class="ui fluid vertical menu">
<div class="header item">{{ctx.Locale.Tr "user.settings"}}</div> <div class="header item">{{ctx.Locale.Tr "user.settings"}}</div>
@ -61,3 +62,4 @@
</a> </a>
</div> </div>
</div> </div>
{{end}}

View file

@ -1,11 +1,16 @@
{{template "user/settings/layout_head" (dict "ctxData" . "pageClass" "user settings security")}} {{template "user/settings/layout_head" (dict "ctxData" . "pageClass" "user settings security" "HideNavbarLinks" .HideNavbarLinks)}}
<div class="user-setting-content"> <div class="user-setting-content">
{{if .MustEnableTwoFactor}}
<div class="ui red message">{{ctx.Locale.Tr "settings.must_enable_2fa"}}</div>
{{end}}
{{template "user/settings/security/twofa" .}} {{template "user/settings/security/twofa" .}}
{{template "user/settings/security/webauthn" .}} {{template "user/settings/security/webauthn" .}}
{{if not .MustEnableTwoFactor}}
{{template "user/settings/security/accountlinks" .}} {{template "user/settings/security/accountlinks" .}}
{{if .EnableOpenIDSignIn}} {{if .EnableOpenIDSignIn}}
{{template "user/settings/security/openid" .}} {{template "user/settings/security/openid" .}}
{{end}} {{end}}
{{end}}
</div> </div>
{{template "user/settings/layout_footer" .}} {{template "user/settings/layout_footer" .}}

View file

@ -5,16 +5,24 @@
<p>{{ctx.Locale.Tr "settings.twofa_desc"}}</p> <p>{{ctx.Locale.Tr "settings.twofa_desc"}}</p>
{{if .TOTPEnrolled}} {{if .TOTPEnrolled}}
<p>{{ctx.Locale.Tr "settings.twofa_is_enrolled"}}</p> <p>{{ctx.Locale.Tr "settings.twofa_is_enrolled"}}</p>
<div class="inline field">
<p>{{ctx.Locale.Tr "settings.twofa_reenroll.description"}}</p>
<a class="ui primary button" href="{{AppSubUrl}}/user/settings/security/two_factor/reenroll">{{ctx.Locale.Tr "settings.twofa_reenroll"}}</a>
</div>
<form class="ui form" action="{{AppSubUrl}}/user/settings/security/two_factor/regenerate_scratch" method="post" enctype="multipart/form-data"> <form class="ui form" action="{{AppSubUrl}}/user/settings/security/two_factor/regenerate_scratch" method="post" enctype="multipart/form-data">
{{.CsrfTokenHtml}} {{.CsrfTokenHtml}}
<p>{{ctx.Locale.Tr "settings.regenerate_scratch_token_desc"}}</p> <p>{{ctx.Locale.Tr "settings.regenerate_scratch_token_desc"}}</p>
<button class="ui primary button">{{ctx.Locale.Tr "settings.twofa_scratch_token_regenerate"}}</button> <button class="ui primary button">{{ctx.Locale.Tr "settings.twofa_scratch_token_regenerate"}}</button>
</form> </form>
{{if .SignedUser.MustHaveTwoFactor}}
<p class="tw-mt-4 tw-mb-0">{{ctx.Locale.Tr "settings.twofa_unroll_unavailable"}}</p>
{{else}}
<form class="ui form" action="{{AppSubUrl}}/user/settings/security/two_factor/disable" method="post" enctype="multipart/form-data" id="disable-form"> <form class="ui form" action="{{AppSubUrl}}/user/settings/security/two_factor/disable" method="post" enctype="multipart/form-data" id="disable-form">
{{.CsrfTokenHtml}} {{.CsrfTokenHtml}}
<p>{{ctx.Locale.Tr "settings.twofa_disable_note"}}</p> <p>{{ctx.Locale.Tr "settings.twofa_disable_note"}}</p>
<button class="ui red button delete-button" data-modal-id="disable-twofa" data-type="form" data-form="#disable-form">{{ctx.Locale.Tr "settings.twofa_disable"}}</button> <button class="ui red button delete-button" data-modal-id="disable-twofa" data-type="form" data-form="#disable-form">{{ctx.Locale.Tr "settings.twofa_disable"}}</button>
</form> </form>
{{end}}
{{else}} {{else}}
{{/* The recovery tip is there as a means of encouraging a user to enroll */}} {{/* The recovery tip is there as a means of encouraging a user to enroll */}}
<p>{{ctx.Locale.Tr "settings.twofa_recovery_tip"}}</p> <p>{{ctx.Locale.Tr "settings.twofa_recovery_tip"}}</p>

View file

@ -1,7 +1,11 @@
{{template "user/settings/layout_head" (dict "ctxData" . "pageClass" "user settings twofa")}} {{template "user/settings/layout_head" (dict "ctxData" . "pageClass" "user settings twofa" "HideNavbarLinks" .HideNavbarLinks)}}
<div class="user-setting-content"> <div class="user-setting-content">
<h4 class="ui top attached header"> <h4 class="ui top attached header">
{{if .ReenrollTwofa}}
{{ctx.Locale.Tr "settings.twofa_reenroll"}}
{{else}}
{{ctx.Locale.Tr "settings.twofa_enroll"}} {{ctx.Locale.Tr "settings.twofa_enroll"}}
{{end}}
</h4> </h4>
<div class="ui attached segment"> <div class="ui attached segment">
<p>{{ctx.Locale.Tr "settings.scan_this_image"}}</p> <p>{{ctx.Locale.Tr "settings.scan_this_image"}}</p>

View file

@ -21,11 +21,13 @@ import (
user_model "forgejo.org/models/user" user_model "forgejo.org/models/user"
"forgejo.org/modules/setting" "forgejo.org/modules/setting"
api "forgejo.org/modules/structs" api "forgejo.org/modules/structs"
"forgejo.org/modules/test"
"forgejo.org/modules/util" "forgejo.org/modules/util"
packages_service "forgejo.org/services/packages" packages_service "forgejo.org/services/packages"
packages_cleanup_service "forgejo.org/services/packages/cleanup" packages_cleanup_service "forgejo.org/services/packages/cleanup"
"forgejo.org/tests" "forgejo.org/tests"
"github.com/pquerna/otp/totp"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
@ -673,3 +675,97 @@ func TestPackageCleanup(t *testing.T) {
} }
}) })
} }
func TestPackageWithTwoFactor(t *testing.T) {
defer tests.PrepareTestEnv(t)()
adminUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
normalUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4})
runTest := func(t *testing.T, doer *user_model.User, useTOTP bool, expectedStatus int) {
t.Helper()
if doer != nil {
defer unittest.AssertSuccessfulDelete(t, &auth_model.TwoFactor{UID: doer.ID})
}
passcode := func() string {
if !useTOTP {
return ""
}
otpKey, err := totp.Generate(totp.GenerateOpts{
SecretSize: 40,
Issuer: "forgejo-test",
AccountName: doer.Name,
})
require.NoError(t, err)
require.NoError(t, auth_model.NewTwoFactor(t.Context(), &auth_model.TwoFactor{UID: doer.ID}, otpKey.Secret()))
passcode, err := totp.GenerateCode(otpKey.Secret(), time.Now())
require.NoError(t, err)
return passcode
}()
url := fmt.Sprintf("/api/v1/packages/%s", normalUser.Name) // a public packge to test
req := NewRequest(t, "GET", url)
if doer != nil {
req.AddBasicAuth(doer.Name)
}
if useTOTP {
MakeRequest(t, req, http.StatusUnauthorized)
req = NewRequest(t, "GET", url).
AddBasicAuth(doer.Name)
req.Header.Set("X-Forgejo-OTP", passcode)
}
MakeRequest(t, req, expectedStatus)
}
t.Run("NoneTwoFactorRequirement", func(t *testing.T) {
// this should be the default, so don't have to set the variable
t.Run("no 2fa", func(t *testing.T) {
runTest(t, adminUser, false, http.StatusOK)
runTest(t, normalUser, false, http.StatusOK)
runTest(t, nil, false, http.StatusOK) // anonymous
})
t.Run("enabled 2fa", func(t *testing.T) {
runTest(t, adminUser, true, http.StatusOK)
runTest(t, normalUser, true, http.StatusOK)
})
})
t.Run("AllTwoFactorRequirement", func(t *testing.T) {
defer test.MockVariableValue(&setting.GlobalTwoFactorRequirement, setting.AllTwoFactorRequirement)()
t.Run("no 2fa", func(t *testing.T) {
runTest(t, adminUser, false, http.StatusForbidden)
runTest(t, normalUser, false, http.StatusForbidden)
runTest(t, nil, false, http.StatusOK) // anonymous
})
t.Run("enabled 2fa", func(t *testing.T) {
runTest(t, adminUser, true, http.StatusOK)
runTest(t, normalUser, true, http.StatusOK)
})
})
t.Run("AdminTwoFactorRequirement", func(t *testing.T) {
defer test.MockVariableValue(&setting.GlobalTwoFactorRequirement, setting.AdminTwoFactorRequirement)()
t.Run("no 2fa", func(t *testing.T) {
runTest(t, adminUser, false, http.StatusForbidden)
runTest(t, normalUser, false, http.StatusOK)
runTest(t, nil, false, http.StatusOK) // anonymous
})
t.Run("enabled 2fa", func(t *testing.T) {
runTest(t, adminUser, true, http.StatusOK)
runTest(t, normalUser, true, http.StatusOK)
})
})
}

View file

@ -5,12 +5,19 @@ package integration
import ( import (
"context" "context"
"net/http"
"net/url" "net/url"
"testing" "testing"
asymkey_model "forgejo.org/models/asymkey" asymkey_model "forgejo.org/models/asymkey"
"forgejo.org/models/auth"
"forgejo.org/models/perm" "forgejo.org/models/perm"
"forgejo.org/models/unittest"
user_model "forgejo.org/models/user"
"forgejo.org/modules/private" "forgejo.org/modules/private"
"forgejo.org/modules/setting"
"forgejo.org/modules/test"
"forgejo.org/tests"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
@ -152,3 +159,90 @@ func TestAPIPrivateServ(t *testing.T) {
assert.Equal(t, int64(20), results.RepoID) assert.Equal(t, int64(20), results.RepoID)
}) })
} }
func TestAPIPrivateServAndNoServWithRequiredTwoFactor(t *testing.T) {
onGiteaRun(t, func(*testing.T, *url.URL) {
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
runTest := func(t *testing.T, user *user_model.User, useTOTP, servAllowed bool) {
t.Helper()
repo, _, reset := tests.CreateDeclarativeRepoWithOptions(t, user, tests.DeclarativeRepoOptions{})
defer reset()
pubKey, err := asymkey_model.AddPublicKey(ctx, user.ID, "tmp-key-"+user.Name, "sk-ecdsa-sha2-nistp256@openssh.com AAAAInNrLWVjZHNhLXNoYTItbmlzdHAyNTZAb3BlbnNzaC5jb20AAAAIbmlzdHAyNTYAAABBBGXEEzWmm1dxb+57RoK5KVCL0w2eNv9cqJX2AGGVlkFsVDhOXHzsadS3LTK4VlEbbrDMJdoti9yM8vclA8IeRacAAAAEc3NoOg== nocomment", 0)
require.NoError(t, err)
defer unittest.AssertSuccessfulDelete(t, &asymkey_model.PublicKey{ID: pubKey.ID})
if useTOTP {
session := loginUser(t, user.Name)
session.EnrollTOTP(t)
session.MakeRequest(t, NewRequest(t, "POST", "/user/logout"), http.StatusOK)
defer unittest.AssertSuccessfulDelete(t, &auth.TwoFactor{UID: user.ID})
}
// Can push to a repo
_, extra := private.ServCommand(ctx, pubKey.ID, user.Name, repo.Name, perm.AccessModeWrite, "git-upload-pack", "")
_, _, err = private.ServNoCommand(ctx, pubKey.ID)
if servAllowed {
require.NoError(t, extra.Error)
require.NoError(t, err)
} else {
require.Error(t, extra.Error)
require.Error(t, err)
}
}
adminUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
normalUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4})
restrictedUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 29})
t.Run("NoneTwoFactorRequirement", func(t *testing.T) {
// this should be the default, so don't have to set the variable
t.Run("no 2fa", func(t *testing.T) {
runTest(t, adminUser, false, true)
runTest(t, normalUser, false, true)
runTest(t, restrictedUser, false, true)
})
t.Run("enabled 2fa", func(t *testing.T) {
runTest(t, adminUser, true, true)
runTest(t, normalUser, true, true)
runTest(t, restrictedUser, true, true)
})
})
t.Run("AllTwoFactorRequirement", func(t *testing.T) {
defer test.MockVariableValue(&setting.GlobalTwoFactorRequirement, setting.AllTwoFactorRequirement)()
t.Run("no 2fa", func(t *testing.T) {
runTest(t, adminUser, false, false)
runTest(t, normalUser, false, false)
runTest(t, restrictedUser, false, false)
})
t.Run("enabled 2fa", func(t *testing.T) {
runTest(t, adminUser, true, true)
runTest(t, normalUser, true, true)
runTest(t, restrictedUser, true, true)
})
})
t.Run("AdminTwoFactorRequirement", func(t *testing.T) {
defer test.MockVariableValue(&setting.GlobalTwoFactorRequirement, setting.AdminTwoFactorRequirement)()
t.Run("no 2fa", func(t *testing.T) {
runTest(t, adminUser, false, false)
runTest(t, normalUser, false, true)
runTest(t, restrictedUser, false, true)
})
t.Run("enabled 2fa", func(t *testing.T) {
runTest(t, adminUser, true, true)
runTest(t, normalUser, true, true)
runTest(t, restrictedUser, true, true)
})
})
})
}

View file

@ -4,7 +4,9 @@
package integration package integration
import ( import (
"fmt"
"net/http" "net/http"
"strings"
"testing" "testing"
"time" "time"
@ -12,6 +14,9 @@ import (
"forgejo.org/models/db" "forgejo.org/models/db"
"forgejo.org/models/unittest" "forgejo.org/models/unittest"
user_model "forgejo.org/models/user" user_model "forgejo.org/models/user"
"forgejo.org/modules/setting"
"forgejo.org/modules/test"
"forgejo.org/modules/translation"
"forgejo.org/tests" "forgejo.org/tests"
"github.com/pquerna/otp/totp" "github.com/pquerna/otp/totp"
@ -79,3 +84,127 @@ func TestAPIWebAuthn(t *testing.T) {
assert.Equal(t, "Basic authorization is not allowed while having security keys enrolled", userParsed.Message) assert.Equal(t, "Basic authorization is not allowed while having security keys enrolled", userParsed.Message)
} }
func TestAPIWithRequiredTwoFactor(t *testing.T) {
defer tests.PrepareTestEnv(t)()
type userResponse struct {
Message string `json:"message"`
}
locale := translation.NewLocale("en-US")
adminUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
normalUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4})
inactiveUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 9})
restrictedUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 29})
prohibitLoginUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 37})
require2FaMessage := locale.TrString("error.must_enable_2fa", fmt.Sprintf("%suser/settings/security", setting.AppURL))
const prohibitedMessage = "This account is prohibited from signing in, please contact your site administrator."
const loginNotAllowedMessage = "user is not allowed login"
runTest := func(t *testing.T, user *user_model.User, useTOTP bool, status int, messagePrefix string) {
t.Helper()
defer unittest.AssertSuccessfulDelete(t, &auth_model.TwoFactor{UID: user.ID})
passcode := func() string {
if !useTOTP {
return ""
}
otpKey, err := totp.Generate(totp.GenerateOpts{
SecretSize: 40,
Issuer: "forgejo-test",
AccountName: user.Name,
})
require.NoError(t, err)
require.NoError(t, auth_model.NewTwoFactor(t.Context(), &auth_model.TwoFactor{UID: user.ID}, otpKey.Secret()))
passcode, err := totp.GenerateCode(otpKey.Secret(), time.Now())
require.NoError(t, err)
return passcode
}()
req := NewRequest(t, "GET", "/api/v1/user").
AddBasicAuth(user.Name)
if useTOTP {
MakeRequest(t, req, http.StatusUnauthorized)
req = NewRequestf(t, "GET", "/api/v1/user").
AddBasicAuth(user.Name)
req.Header.Set("X-Forgejo-OTP", passcode)
}
resp := MakeRequest(t, req, status)
if messagePrefix != "" {
var response userResponse
DecodeJSON(t, resp, &response)
assert.True(t, strings.HasPrefix(response.Message, messagePrefix))
}
}
t.Run("NoneTwoFactorRequirement", func(t *testing.T) {
// this should be the default, so don't have to set the variable
t.Run("no 2fa", func(t *testing.T) {
runTest(t, adminUser, false, http.StatusOK, "")
runTest(t, normalUser, false, http.StatusOK, "")
runTest(t, inactiveUser, false, http.StatusForbidden, prohibitedMessage)
runTest(t, restrictedUser, false, http.StatusOK, "")
runTest(t, prohibitLoginUser, false, http.StatusUnauthorized, loginNotAllowedMessage)
})
t.Run("enabled 2fa", func(t *testing.T) {
runTest(t, adminUser, true, http.StatusOK, "")
runTest(t, normalUser, true, http.StatusOK, "")
runTest(t, inactiveUser, true, http.StatusForbidden, prohibitedMessage)
runTest(t, restrictedUser, true, http.StatusOK, "")
runTest(t, prohibitLoginUser, true, http.StatusUnauthorized, loginNotAllowedMessage)
})
})
t.Run("AllTwoFactorRequirement", func(t *testing.T) {
defer test.MockVariableValue(&setting.GlobalTwoFactorRequirement, setting.AllTwoFactorRequirement)()
t.Run("no 2fa", func(t *testing.T) {
runTest(t, adminUser, false, http.StatusForbidden, require2FaMessage)
runTest(t, normalUser, false, http.StatusForbidden, require2FaMessage)
runTest(t, inactiveUser, false, http.StatusForbidden, prohibitedMessage)
runTest(t, restrictedUser, false, http.StatusForbidden, require2FaMessage)
runTest(t, prohibitLoginUser, false, http.StatusUnauthorized, loginNotAllowedMessage)
})
t.Run("enabled 2fa", func(t *testing.T) {
runTest(t, adminUser, true, http.StatusOK, "")
runTest(t, normalUser, true, http.StatusOK, "")
runTest(t, inactiveUser, true, http.StatusForbidden, prohibitedMessage)
runTest(t, restrictedUser, true, http.StatusOK, "")
runTest(t, prohibitLoginUser, true, http.StatusUnauthorized, loginNotAllowedMessage)
})
})
t.Run("AdminTwoFactorRequirement", func(t *testing.T) {
defer test.MockVariableValue(&setting.GlobalTwoFactorRequirement, setting.AdminTwoFactorRequirement)()
t.Run("no 2fa", func(t *testing.T) {
runTest(t, adminUser, false, http.StatusForbidden, require2FaMessage)
runTest(t, normalUser, false, http.StatusOK, "")
runTest(t, inactiveUser, false, http.StatusForbidden, prohibitedMessage)
runTest(t, restrictedUser, false, http.StatusOK, "")
runTest(t, prohibitLoginUser, false, http.StatusUnauthorized, loginNotAllowedMessage)
})
t.Run("enabled 2fa", func(t *testing.T) {
runTest(t, adminUser, true, http.StatusOK, "")
runTest(t, normalUser, true, http.StatusOK, "")
runTest(t, inactiveUser, true, http.StatusForbidden, prohibitedMessage)
runTest(t, restrictedUser, true, http.StatusOK, "")
runTest(t, prohibitLoginUser, true, http.StatusUnauthorized, loginNotAllowedMessage)
})
})
}

View file

@ -18,9 +18,17 @@ import (
// error pages sometimes which can be hard to reach otherwise. // error pages sometimes which can be hard to reach otherwise.
// This file is a test of various attributes on those pages. // This file is a test of various attributes on those pages.
func enableDevtest() func() {
resetIsProd := test.MockVariableValue(&setting.IsProd, false)
resetRoutes := test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())
return func() {
resetIsProd()
resetRoutes()
}
}
func TestDevtestErrorpages(t *testing.T) { func TestDevtestErrorpages(t *testing.T) {
defer test.MockVariableValue(&setting.IsProd, false)() defer enableDevtest()()
defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())()
t.Run("Server error", func(t *testing.T) { t.Run("Server error", func(t *testing.T) {
// `/devtest/error/x` returns 500 for any x by default. // `/devtest/error/x` returns 500 for any x by default.

View file

@ -76,17 +76,28 @@ func (doc *HTMLDoc) Find(selector string) *goquery.Selection {
return doc.doc.Find(selector) return doc.doc.Find(selector)
} }
// FindByText gets all elements by selector that also has the given text
func (doc *HTMLDoc) FindByText(selector, text string) *goquery.Selection {
return doc.doc.Find(selector).FilterFunction(func(i int, s *goquery.Selection) bool {
return s.Text() == text
})
}
// GetCSRF for getting CSRF token value from input // GetCSRF for getting CSRF token value from input
func (doc *HTMLDoc) GetCSRF() string { func (doc *HTMLDoc) GetCSRF() string {
return doc.GetInputValueByName("_csrf") return doc.GetInputValueByName("_csrf")
} }
// AssertElement check if element by selector exists or does not exist depending on checkExists // AssertSelection check if selection exists or does not exist depending on checkExists
func (doc *HTMLDoc) AssertElement(t testing.TB, selector string, checkExists bool) { func (doc *HTMLDoc) AssertSelection(t testing.TB, selection *goquery.Selection, checkExists bool) {
sel := doc.doc.Find(selector)
if checkExists { if checkExists {
assert.Equal(t, 1, sel.Length()) assert.Equal(t, 1, selection.Length())
} else { } else {
assert.Equal(t, 0, sel.Length()) assert.Equal(t, 0, selection.Length())
} }
} }
// AssertElement check if element by selector exists or does not exist depending on checkExists
func (doc *HTMLDoc) AssertElement(t testing.TB, selector string, checkExists bool) {
doc.AssertSelection(t, doc.doc.Find(selector), checkExists)
}

View file

@ -33,6 +33,7 @@ import (
user_model "forgejo.org/models/user" user_model "forgejo.org/models/user"
"forgejo.org/modules/graceful" "forgejo.org/modules/graceful"
"forgejo.org/modules/json" "forgejo.org/modules/json"
"forgejo.org/modules/keying"
"forgejo.org/modules/log" "forgejo.org/modules/log"
"forgejo.org/modules/setting" "forgejo.org/modules/setting"
"forgejo.org/modules/testlogger" "forgejo.org/modules/testlogger"
@ -50,6 +51,7 @@ import (
"github.com/markbates/goth/gothic" "github.com/markbates/goth/gothic"
goth_github "github.com/markbates/goth/providers/github" goth_github "github.com/markbates/goth/providers/github"
goth_gitlab "github.com/markbates/goth/providers/gitlab" goth_gitlab "github.com/markbates/goth/providers/gitlab"
"github.com/pquerna/otp/totp"
"github.com/santhosh-tekuri/jsonschema/v6" "github.com/santhosh-tekuri/jsonschema/v6"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
@ -276,6 +278,30 @@ func (s *TestSession) MakeRequestNilResponseHashSumRecorder(t testing.TB, rw *Re
return resp return resp
} }
func (s *TestSession) EnrollTOTP(t testing.TB) {
t.Helper()
req := NewRequest(t, "GET", "/user/settings/security/two_factor/enroll")
resp := s.MakeRequest(t, req, http.StatusOK)
htmlDoc := NewHTMLParser(t, resp.Body)
totpSecretKey, has := htmlDoc.Find(".twofa img[src^='data:image/png;base64']").Attr("alt")
assert.True(t, has)
currentTOTP, err := totp.GenerateCode(totpSecretKey, time.Now())
require.NoError(t, err)
req = NewRequestWithValues(t, "POST", "/user/settings/security/two_factor/enroll", map[string]string{
"_csrf": htmlDoc.GetCSRF(),
"passcode": currentTOTP,
})
s.MakeRequest(t, req, http.StatusSeeOther)
flashCookie := s.GetCookie(gitea_context.CookieNameFlash)
assert.NotNil(t, flashCookie)
assert.Contains(t, flashCookie.Value, "success%3DYour%2Baccount%2Bhas%2Bbeen%2Bsuccessfully%2Benrolled.")
}
const userPassword = "password" const userPassword = "password"
func emptyTestSession(t testing.TB) *TestSession { func emptyTestSession(t testing.TB) *TestSession {
@ -418,6 +444,40 @@ func loginUserWithPasswordRemember(t testing.TB, userName, password string, reme
return session return session
} }
func loginUserWithTOTP(t testing.TB, user *user_model.User) *TestSession {
t.Helper()
session := loginUser(t, user.Name)
twoFactor, err := auth.GetTwoFactorByUID(db.DefaultContext, user.ID)
require.NoError(t, err)
key := keying.DeriveKey(keying.ContextTOTP)
code, err := key.Decrypt(twoFactor.Secret, keying.ColumnAndID("secret", twoFactor.ID))
require.NoError(t, err)
passcode, err := totp.GenerateCode(string(code), time.Now())
require.NoError(t, err)
req := NewRequestWithValues(t, "POST", "/user/two_factor", map[string]string{
"_csrf": GetCSRF(t, session, "/user/two_factor"),
"passcode": passcode,
})
session.MakeRequest(t, req, http.StatusSeeOther)
return session
}
func loginUserMaybeTOTP(t testing.TB, user *user_model.User, useTOTP bool) *TestSession {
if useTOTP {
sess := loginUser(t, user.Name)
sess.EnrollTOTP(t)
sess.MakeRequest(t, NewRequest(t, "POST", "/user/logout"), http.StatusOK)
return loginUserWithTOTP(t, user)
}
return loginUser(t, user.Name)
}
// token has to be unique this counter take care of // token has to be unique this counter take care of
var tokenCounter int64 var tokenCounter int64

View file

@ -14,6 +14,7 @@ import (
user_model "forgejo.org/models/user" user_model "forgejo.org/models/user"
"forgejo.org/modules/setting" "forgejo.org/modules/setting"
"forgejo.org/modules/test" "forgejo.org/modules/test"
"forgejo.org/modules/translation"
"forgejo.org/tests" "forgejo.org/tests"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@ -156,6 +157,78 @@ func TestSettingSecurityAuthSource(t *testing.T) {
assert.Contains(t, resp.Body.String(), `gitlab-inactive`) assert.Contains(t, resp.Body.String(), `gitlab-inactive`)
} }
func TestSettingSecurityTwoFactorRequirement(t *testing.T) {
defer tests.PrepareTestEnv(t)()
locale := translation.NewLocale("en-US")
runTest := func(t *testing.T, user *user_model.User, forceTOTP, showReroll, showUnroll bool) {
t.Helper()
defer unittest.AssertSuccessfulDelete(t, &auth_model.TwoFactor{UID: user.ID})
useTOTP := forceTOTP || user.MustHaveTwoFactor()
session := loginUserMaybeTOTP(t, user, useTOTP)
resp := session.MakeRequest(t, NewRequest(t, "GET", "user/settings/security"), http.StatusOK)
htmlDoc := NewHTMLParser(t, resp.Body)
htmlDoc.AssertSelection(t, htmlDoc.FindByText("a", locale.TrString("settings.twofa_reenroll")), showReroll)
htmlDoc.AssertElement(t, "#disable-form", showUnroll)
htmlDoc.AssertSelection(t, htmlDoc.FindByText("p", locale.TrString("settings.twofa_unroll_unavailable")), showReroll && !showUnroll)
req := NewRequestWithValues(t, "POST", "user/settings/security/two_factor/disable", map[string]string{
"_csrf": htmlDoc.GetCSRF(),
})
if user.MustHaveTwoFactor() {
session.MakeRequest(t, req, http.StatusNotFound)
} else {
resp := session.MakeRequest(t, req, http.StatusSeeOther)
assert.Equal(t, "/user/settings/security", resp.Header().Get("Location"))
}
}
adminUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
normalUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4})
restrictedUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 29})
t.Run("NoneTwoFactorRequirement", func(t *testing.T) {
t.Run("no 2fa", func(t *testing.T) {
runTest(t, adminUser, false, false, false)
runTest(t, normalUser, false, false, false)
runTest(t, restrictedUser, false, false, false)
})
t.Run("enabled 2fa", func(t *testing.T) {
runTest(t, adminUser, true, true, true)
runTest(t, normalUser, true, true, true)
runTest(t, restrictedUser, true, true, true)
})
})
t.Run("AllTwoFactorRequirement", func(t *testing.T) {
defer test.MockVariableValue(&setting.GlobalTwoFactorRequirement, setting.AllTwoFactorRequirement)()
runTest(t, adminUser, false, true, false)
runTest(t, normalUser, false, true, false)
runTest(t, restrictedUser, false, true, false)
})
t.Run("AdminTwoFactorRequirement", func(t *testing.T) {
defer test.MockVariableValue(&setting.GlobalTwoFactorRequirement, setting.AdminTwoFactorRequirement)()
t.Run("no 2fa", func(t *testing.T) {
runTest(t, adminUser, false, true, false)
runTest(t, normalUser, false, false, false)
runTest(t, restrictedUser, false, false, false)
})
t.Run("enabled 2fa", func(t *testing.T) {
runTest(t, adminUser, true, true, false)
runTest(t, normalUser, true, true, true)
runTest(t, restrictedUser, true, true, true)
})
})
}
func TestUserAvatarSizeNotice(t *testing.T) { func TestUserAvatarSizeNotice(t *testing.T) {
defer tests.PrepareTestEnv(t)() defer tests.PrepareTestEnv(t)()

View file

@ -5,11 +5,13 @@
package integration package integration
import ( import (
"fmt"
"net/http" "net/http"
"net/url" "net/url"
"strings" "strings"
"testing" "testing"
"forgejo.org/models/auth"
"forgejo.org/models/unittest" "forgejo.org/models/unittest"
user_model "forgejo.org/models/user" user_model "forgejo.org/models/user"
"forgejo.org/modules/setting" "forgejo.org/modules/setting"
@ -141,3 +143,136 @@ func TestDisableSignin(t *testing.T) {
}) })
}) })
} }
func TestGlobalTwoFactorRequirement(t *testing.T) {
defer tests.PrepareTestEnv(t)()
locale := translation.NewLocale("en-US")
adminUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
normalUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4})
restrictedUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 29})
runTest := func(t *testing.T, user *user_model.User, useTOTP, loginAllowed bool) {
t.Helper()
defer unittest.AssertSuccessfulDelete(t, &auth.TwoFactor{UID: user.ID})
session := loginUserMaybeTOTP(t, user, useTOTP)
req := NewRequest(t, "GET", fmt.Sprintf("/%s", user.Name))
if loginAllowed {
session.MakeRequest(t, req, http.StatusOK)
// not found page
req = NewRequest(t, "GET", "/absolutly/not/found")
req.Header.Add("Accept", "text/html")
resp := session.MakeRequest(t, req, http.StatusNotFound)
htmlDoc := NewHTMLParser(t, resp.Body)
assert.Greater(t, htmlDoc.Find(".navbar-left > a.item").Length(), 1) // show the Logo, and other links
assert.Greater(t, htmlDoc.Find(".navbar-right .user-menu a.item").Length(), 1)
// 500 page
reset := enableDevtest()
req = NewRequest(t, "GET", "/devtest/error/500")
req.Header.Add("Accept", "text/html")
resp = session.MakeRequest(t, req, http.StatusInternalServerError)
htmlDoc = NewHTMLParser(t, resp.Body)
assert.Equal(t, 1, htmlDoc.Find(".navbar-left > a.item").Length())
htmlDoc.AssertElement(t, ".navbar-right", false)
reset()
} else {
resp := session.MakeRequest(t, req, http.StatusSeeOther)
assert.Equal(t, "/user/settings/security", resp.Header().Get("Location"))
// not found page
req = NewRequest(t, "GET", "/absolutly/not/found")
req.Header.Add("Accept", "text/html")
resp = session.MakeRequest(t, req, http.StatusNotFound)
htmlDoc := NewHTMLParser(t, resp.Body)
assert.Equal(t, 1, htmlDoc.Find(".navbar-left > a.item").Length()) // only show the Logo, no other links
userLinks := htmlDoc.Find(".navbar-right .user-menu a.item")
assert.Equal(t, 1, userLinks.Length()) // only logout link
assert.Equal(t, "Sign out", strings.TrimSpace(userLinks.Text()))
// 500 page
reset := enableDevtest()
req = NewRequest(t, "GET", "/devtest/error/500")
req.Header.Add("Accept", "text/html")
resp = session.MakeRequest(t, req, http.StatusInternalServerError)
htmlDoc = NewHTMLParser(t, resp.Body)
assert.Equal(t, 1, htmlDoc.Find(".navbar-left > a.item").Length())
htmlDoc.AssertElement(t, ".navbar-right", false)
reset()
// 2fa page
req = NewRequest(t, "GET", "/user/settings/security")
resp = session.MakeRequest(t, req, http.StatusOK)
htmlDoc = NewHTMLParser(t, resp.Body)
assert.Equal(t, locale.TrString("settings.must_enable_2fa"), htmlDoc.Find(".ui.red.message").Text())
assert.Equal(t, 1, htmlDoc.Find(".navbar-left > a.item").Length()) // only show the Logo, no other links
userLinks = htmlDoc.Find(".navbar-right .user-menu a.item")
assert.Equal(t, 1, userLinks.Length()) // only logout link
assert.Equal(t, "Sign out", strings.TrimSpace(userLinks.Text()))
assert.Equal(t, 0, htmlDoc.FindByText("a", locale.TrString("settings.twofa_reenroll")).Length())
headings := htmlDoc.Find(".user-setting-content h4.attached.header")
assert.Equal(t, 2, headings.Length())
assert.Equal(t, locale.TrString("settings.twofa"), strings.TrimSpace(headings.First().Text()))
assert.Equal(t, locale.TrString("settings.webauthn"), strings.TrimSpace(headings.Last().Text()))
}
}
t.Run("NoneTwoFactorRequirement", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
t.Run("no 2fa", func(t *testing.T) {
runTest(t, adminUser, false, true)
runTest(t, normalUser, false, true)
runTest(t, restrictedUser, false, true)
})
t.Run("enabled 2fa", func(t *testing.T) {
runTest(t, adminUser, true, true)
runTest(t, normalUser, true, true)
runTest(t, restrictedUser, true, true)
})
})
t.Run("AllTwoFactorRequirement", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
defer test.MockVariableValue(&setting.GlobalTwoFactorRequirement, setting.AllTwoFactorRequirement)()
t.Run("no 2fa", func(t *testing.T) {
runTest(t, adminUser, false, false)
runTest(t, normalUser, false, false)
runTest(t, restrictedUser, false, false)
})
t.Run("enabled 2fa", func(t *testing.T) {
runTest(t, adminUser, true, true)
runTest(t, normalUser, true, true)
runTest(t, restrictedUser, true, true)
})
})
t.Run("AdminTwoFactorRequirement", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
defer test.MockVariableValue(&setting.GlobalTwoFactorRequirement, setting.AdminTwoFactorRequirement)()
t.Run("no 2fa", func(t *testing.T) {
runTest(t, adminUser, false, false)
runTest(t, normalUser, false, true)
runTest(t, restrictedUser, false, true)
})
t.Run("enabled 2fa", func(t *testing.T) {
runTest(t, adminUser, true, true)
runTest(t, normalUser, true, true)
runTest(t, restrictedUser, true, true)
})
})
}

View file

@ -853,32 +853,6 @@ func TestUserTOTPEnrolled(t *testing.T) {
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
session := loginUser(t, user.Name) session := loginUser(t, user.Name)
enrollTOTP := func(t *testing.T) {
t.Helper()
req := NewRequest(t, "GET", "/user/settings/security/two_factor/enroll")
resp := session.MakeRequest(t, req, http.StatusOK)
htmlDoc := NewHTMLParser(t, resp.Body)
totpSecretKey, has := htmlDoc.Find(".twofa img[src^='data:image/png;base64']").Attr("alt")
assert.True(t, has)
currentTOTP, err := totp.GenerateCode(totpSecretKey, time.Now())
require.NoError(t, err)
req = NewRequestWithValues(t, "POST", "/user/settings/security/two_factor/enroll", map[string]string{
"_csrf": htmlDoc.GetCSRF(),
"passcode": currentTOTP,
})
session.MakeRequest(t, req, http.StatusSeeOther)
flashCookie := session.GetCookie(gitea_context.CookieNameFlash)
assert.NotNil(t, flashCookie)
assert.Contains(t, flashCookie.Value, "success%3DYour%2Baccount%2Bhas%2Bbeen%2Bsuccessfully%2Benrolled.")
unittest.AssertSuccessfulDelete(t, &auth_model.TwoFactor{UID: user.ID})
}
t.Run("No WebAuthn enabled", func(t *testing.T) { t.Run("No WebAuthn enabled", func(t *testing.T) {
defer tests.PrintCurrentTest(t)() defer tests.PrintCurrentTest(t)()
@ -891,7 +865,8 @@ func TestUserTOTPEnrolled(t *testing.T) {
called = true called = true
})() })()
enrollTOTP(t) session.EnrollTOTP(t)
unittest.AssertSuccessfulDelete(t, &auth_model.TwoFactor{UID: user.ID})
assert.True(t, called) assert.True(t, called)
}) })
@ -909,12 +884,122 @@ func TestUserTOTPEnrolled(t *testing.T) {
})() })()
unittest.AssertSuccessfulInsert(t, &auth_model.WebAuthnCredential{UserID: user.ID, Name: "Cueball's primary key"}) unittest.AssertSuccessfulInsert(t, &auth_model.WebAuthnCredential{UserID: user.ID, Name: "Cueball's primary key"})
enrollTOTP(t) session.EnrollTOTP(t)
unittest.AssertSuccessfulDelete(t, &auth_model.TwoFactor{UID: user.ID})
assert.True(t, called) assert.True(t, called)
}) })
} }
func TestUserTOTPReenroll(t *testing.T) {
defer tests.PrepareTestEnv(t)()
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4})
session := loginUser(t, user.Name)
resp := session.MakeRequest(t, NewRequest(t, "GET", "/user/settings/security/two_factor/reenroll"), http.StatusSeeOther)
assert.Equal(t, "/user/settings/security", resp.Header().Get("Location"))
session.EnrollTOTP(t)
resp = session.MakeRequest(t, NewRequest(t, "GET", "/user/settings/security/two_factor/reenroll"), http.StatusOK)
htmlDoc := NewHTMLParser(t, resp.Body)
totpSecretKey, has := htmlDoc.Find(".twofa img[src^='data:image/png;base64']").Attr("alt")
assert.True(t, has)
currentTOTP, err := totp.GenerateCode(totpSecretKey, time.Now())
require.NoError(t, err)
req := NewRequestWithValues(t, "POST", "/user/settings/security/two_factor/reenroll", map[string]string{
"_csrf": htmlDoc.GetCSRF(),
"passcode": currentTOTP,
})
session.MakeRequest(t, req, http.StatusSeeOther)
flashCookie := session.GetCookie(gitea_context.CookieNameFlash)
assert.NotNil(t, flashCookie)
assert.Contains(t, flashCookie.Value, "success%3DYour%2Baccount%2Bhas%2Bbeen%2Bsuccessfully%2Benrolled.")
}
func TestUserTOTPDisable(t *testing.T) {
defer tests.PrepareTestEnv(t)()
runTest := func(t *testing.T, user *user_model.User, useTOTP, disableAllowed bool, status int, flashMessage string) {
t.Helper()
defer unittest.AssertSuccessfulDelete(t, &auth_model.TwoFactor{UID: user.ID})
session := loginUserMaybeTOTP(t, user, useTOTP)
resp := session.MakeRequest(t, NewRequest(t, "GET", "user/settings/security"), http.StatusOK)
htmlDoc := NewHTMLParser(t, resp.Body)
htmlDoc.AssertElement(t, "#disable-form", disableAllowed)
req := NewRequestWithValues(t, "POST", "user/settings/security/two_factor/disable", map[string]string{
"_csrf": htmlDoc.GetCSRF(),
})
if status == http.StatusSeeOther {
resp := session.MakeRequest(t, req, http.StatusSeeOther)
assert.Equal(t, "/user/settings/security", resp.Header().Get("Location"))
} else {
session.MakeRequest(t, req, status)
}
if flashMessage != "" {
flashCookie := session.GetCookie(gitea_context.CookieNameFlash)
assert.NotNil(t, flashCookie)
if disableAllowed {
assert.Contains(t, flashCookie.Value, fmt.Sprintf("success%%3D%s", flashMessage))
} else {
assert.Contains(t, flashCookie.Value, fmt.Sprintf("error%%3D%s", flashMessage))
}
}
}
adminUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
normalUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4})
restrictedUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 29})
const twofaNotEnrolled = "Your%2Baccount%2Bis%2Bnot%2Bcurrently%2Benrolled%2Bin%2Btwo-factor%2Bauthentication."
const twofaDisabled = "Two-factor%2Bauthentication%2Bhas%2Bbeen%2Bdisabled."
t.Run("NoneTwoFactorRequirement", func(t *testing.T) {
t.Run("no 2fa", func(t *testing.T) {
runTest(t, adminUser, false, false, http.StatusSeeOther, twofaNotEnrolled)
runTest(t, normalUser, false, false, http.StatusSeeOther, twofaNotEnrolled)
runTest(t, restrictedUser, false, false, http.StatusSeeOther, twofaNotEnrolled)
})
t.Run("enabled 2fa", func(t *testing.T) {
runTest(t, adminUser, true, true, http.StatusSeeOther, twofaDisabled)
runTest(t, normalUser, true, true, http.StatusSeeOther, twofaDisabled)
runTest(t, restrictedUser, true, true, http.StatusSeeOther, twofaDisabled)
})
})
t.Run("AllTwoFactorRequirement", func(t *testing.T) {
defer test.MockVariableValue(&setting.GlobalTwoFactorRequirement, setting.AllTwoFactorRequirement)()
runTest(t, adminUser, true, false, http.StatusNotFound, "")
runTest(t, normalUser, true, false, http.StatusNotFound, "")
runTest(t, restrictedUser, true, false, http.StatusNotFound, "")
})
t.Run("AdminTwoFactorRequirement", func(t *testing.T) {
defer test.MockVariableValue(&setting.GlobalTwoFactorRequirement, setting.AdminTwoFactorRequirement)()
t.Run("no 2fa", func(t *testing.T) {
runTest(t, normalUser, false, false, http.StatusSeeOther, twofaNotEnrolled)
runTest(t, restrictedUser, false, false, http.StatusSeeOther, twofaNotEnrolled)
})
t.Run("enabled 2fa", func(t *testing.T) {
runTest(t, adminUser, true, false, http.StatusNotFound, "")
runTest(t, normalUser, true, true, http.StatusSeeOther, twofaDisabled)
runTest(t, restrictedUser, true, true, http.StatusSeeOther, twofaDisabled)
})
})
}
func TestUserRepos(t *testing.T) { func TestUserRepos(t *testing.T) {
defer tests.PrepareTestEnv(t)() defer tests.PrepareTestEnv(t)()