mirror of
https://codeberg.org/forgejo/forgejo.git
synced 2025-08-19 08:51:10 +00:00
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:
parent
ff99331225
commit
d6838462b8
29 changed files with 1179 additions and 62 deletions
|
@ -449,6 +449,9 @@ INTERNAL_TOKEN =
|
|||
;; How long to remember that a user is logged in before requiring relogin (in days)
|
||||
;LOGIN_REMEMBER_DAYS = 31
|
||||
;;
|
||||
;; Require 2FA globally for none|all|admin.
|
||||
;GLOBAL_TWO_FACTOR_REQUIREMENT = none
|
||||
;;
|
||||
;; Name of cookie used to store authentication information.
|
||||
;COOKIE_REMEMBER_NAME = gitea_incredible
|
||||
;;
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
// 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.
|
||||
func (u *User) IsLocal() bool {
|
||||
return u.LoginType <= auth.Plain
|
||||
|
|
|
@ -637,6 +637,145 @@ func TestGetAllAdmins(t *testing.T) {
|
|||
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) {
|
||||
defer test.MockVariableValue(&setting.Service.AllowedUserVisibilityModesSlice, []bool{true, false, true})()
|
||||
|
||||
|
|
|
@ -20,6 +20,7 @@ var (
|
|||
SecretKey string
|
||||
InternalToken string // internal access token
|
||||
LogInRememberDays int
|
||||
GlobalTwoFactorRequirement TwoFactorRequirementType
|
||||
CookieRememberName string
|
||||
ReverseProxyAuthUser string
|
||||
ReverseProxyAuthEmail string
|
||||
|
@ -113,6 +114,8 @@ func loadSecurityFrom(rootCfg ConfigProvider) {
|
|||
}
|
||||
keying.Init([]byte(SecretKey))
|
||||
|
||||
GlobalTwoFactorRequirement = NewTwoFactorRequirementType(sec.Key("GLOBAL_TWO_FACTOR_REQUIREMENT").String())
|
||||
|
||||
CookieRememberName = sec.Key("COOKIE_REMEMBER_NAME").MustString("gitea_incredible")
|
||||
|
||||
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.")
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
|
|
@ -112,7 +112,17 @@
|
|||
"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.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.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",
|
||||
"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",
|
||||
|
|
|
@ -4,8 +4,10 @@
|
|||
package shared
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
auth_model "forgejo.org/models/auth"
|
||||
"forgejo.org/modules/log"
|
||||
"forgejo.org/modules/setting"
|
||||
"forgejo.org/routers/common"
|
||||
|
@ -92,6 +94,25 @@ func verifyAuthWithOptions(options *common.VerifyOptions) func(ctx *context.APIC
|
|||
})
|
||||
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.
|
||||
|
|
|
@ -9,6 +9,7 @@ import (
|
|||
"strings"
|
||||
|
||||
asymkey_model "forgejo.org/models/asymkey"
|
||||
"forgejo.org/models/auth"
|
||||
"forgejo.org/models/perm"
|
||||
access_model "forgejo.org/models/perm/access"
|
||||
repo_model "forgejo.org/models/repo"
|
||||
|
@ -22,6 +23,27 @@ import (
|
|||
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
|
||||
func ServNoCommand(ctx *context.PrivateContext) {
|
||||
keyID := ctx.ParamsInt64(":keyid")
|
||||
|
@ -69,6 +91,12 @@ func ServNoCommand(ctx *context.PrivateContext) {
|
|||
})
|
||||
return
|
||||
}
|
||||
|
||||
checkTwoFactor(ctx, user)
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
|
||||
results.Owner = user
|
||||
}
|
||||
ctx.JSON(http.StatusOK, &results)
|
||||
|
@ -266,6 +294,11 @@ func ServCommand(ctx *context.PrivateContext) {
|
|||
return
|
||||
}
|
||||
|
||||
checkTwoFactor(ctx, user)
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
|
||||
results.UserName = user.Name
|
||||
if !user.KeepEmailPrivate {
|
||||
results.UserEmail = user.Email
|
||||
|
|
|
@ -128,6 +128,7 @@ func Config(ctx *context.Context) {
|
|||
ctx.Data["AppBuiltWith"] = setting.AppBuiltWith
|
||||
ctx.Data["Domain"] = setting.Domain
|
||||
ctx.Data["OfflineMode"] = setting.OfflineMode
|
||||
ctx.Data["GlobalTwoFactorRequirement"] = setting.GlobalTwoFactorRequirement
|
||||
ctx.Data["RunUser"] = setting.RunUser
|
||||
ctx.Data["RunMode"] = util.ToTitleCase(setting.RunMode)
|
||||
ctx.Data["GitVersion"] = git.VersionInfo()
|
||||
|
|
|
@ -264,7 +264,7 @@ func ResetPasswdPost(ctx *context.Context) {
|
|||
func MustChangePassword(ctx *context.Context) {
|
||||
ctx.Data["Title"] = ctx.Tr("auth.must_change_password")
|
||||
ctx.Data["ChangePasscodeLink"] = setting.AppSubURL + "/user/settings/change_password"
|
||||
ctx.Data["MustChangePassword"] = true
|
||||
ctx.Data["HideNavbarLinks"] = true
|
||||
ctx.HTML(http.StatusOK, tplMustChangePassword)
|
||||
}
|
||||
|
||||
|
|
|
@ -9,6 +9,7 @@ import (
|
|||
"net/http"
|
||||
"strconv"
|
||||
|
||||
auth_model "forgejo.org/models/auth"
|
||||
"forgejo.org/models/db"
|
||||
repo_model "forgejo.org/models/repo"
|
||||
user_model "forgejo.org/models/user"
|
||||
|
@ -35,18 +36,37 @@ func Home(ctx *context.Context) {
|
|||
if !ctx.Doer.IsActive && setting.Service.RegisterEmailConfirm {
|
||||
ctx.Data["Title"] = ctx.Tr("auth.active_your_account")
|
||||
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())
|
||||
ctx.Data["Title"] = ctx.Tr("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["ChangePasscodeLink"] = setting.AppSubURL + "/user/change_password"
|
||||
middleware.SetRedirectToCookie(ctx.Resp, setting.AppSubURL+ctx.Req.URL.RequestURI())
|
||||
ctx.Redirect(setting.AppSubURL + "/user/settings/change_password")
|
||||
} else {
|
||||
user.Dashboard(ctx)
|
||||
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.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
|
||||
// Check non-logged users landing page.
|
||||
} else if setting.LandingPageURL != setting.LandingPageHome {
|
||||
|
|
|
@ -56,6 +56,21 @@ func DisableTwoFactor(ctx *context.Context) {
|
|||
ctx.Data["Title"] = ctx.Tr("settings")
|
||||
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)
|
||||
if err != nil {
|
||||
if auth.IsErrTwoFactorNotEnrolled(err) {
|
||||
|
@ -82,9 +97,6 @@ func DisableTwoFactor(ctx *context.Context) {
|
|||
ctx.ServerError("SendDisabledTOTP", err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Flash.Success(ctx.Tr("settings.twofa_disabled"))
|
||||
ctx.Redirect(setting.AppSubURL + "/user/settings/security")
|
||||
}
|
||||
|
||||
func twofaGenerateSecretAndQr(ctx *context.Context) bool {
|
||||
|
@ -172,7 +184,6 @@ func EnrollTwoFactor(ctx *context.Context) {
|
|||
|
||||
// EnrollTwoFactorPost handles enrolling the user into 2FA.
|
||||
func EnrollTwoFactorPost(ctx *context.Context) {
|
||||
form := web.GetForm(ctx).(*forms.TwoFactorAuthForm)
|
||||
ctx.Data["Title"] = ctx.Tr("settings")
|
||||
ctx.Data["PageIsSettingsSecurity"] = true
|
||||
|
||||
|
@ -188,6 +199,12 @@ func EnrollTwoFactorPost(ctx *context.Context) {
|
|||
return
|
||||
}
|
||||
|
||||
enrollTwoFactor(ctx)
|
||||
}
|
||||
|
||||
func enrollTwoFactor(ctx *context.Context) {
|
||||
form := web.GetForm(ctx).(*forms.TwoFactorAuthForm)
|
||||
|
||||
if ctx.HasError() {
|
||||
if !twofaGenerateSecretAndQr(ctx) {
|
||||
return
|
||||
|
@ -213,10 +230,10 @@ func EnrollTwoFactorPost(ctx *context.Context) {
|
|||
return
|
||||
}
|
||||
|
||||
t = &auth.TwoFactor{
|
||||
twoFactor := &auth.TwoFactor{
|
||||
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
|
||||
// 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
|
||||
}
|
||||
|
||||
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.
|
||||
// If there is a unique constraint fail we should just tolerate the error
|
||||
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.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)
|
||||
}
|
||||
|
|
|
@ -6,9 +6,11 @@ package web
|
|||
|
||||
import (
|
||||
gocontext "context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
auth_model "forgejo.org/models/auth"
|
||||
"forgejo.org/models/perm"
|
||||
quota_model "forgejo.org/models/quota"
|
||||
"forgejo.org/models/unit"
|
||||
|
@ -169,6 +171,19 @@ func verifyAuthWithOptions(options *common.VerifyOptions) func(ctx *context.Cont
|
|||
ctx.Redirect(setting.AppSubURL + "/")
|
||||
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.
|
||||
|
@ -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) {
|
||||
if !setting.Service.EnableOpenIDSignIn {
|
||||
ctx.Error(http.StatusForbidden)
|
||||
|
@ -564,6 +593,8 @@ func registerRoutes(m *web.Route) {
|
|||
m.Post("/disable", security.DisableTwoFactor)
|
||||
m.Get("/enroll", security.EnrollTwoFactor)
|
||||
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.Post("/request_register", web.Bind(forms.WebauthnRegistrationForm{}), security.WebAuthnRegister)
|
||||
|
@ -576,7 +607,7 @@ func registerRoutes(m *web.Route) {
|
|||
m.Post("/toggle_visibility", security.ToggleOpenIDVisibility)
|
||||
}, openIDSignInEnabled)
|
||||
m.Post("/account_link", linkAccountEnabled, security.DeleteAccountLink)
|
||||
})
|
||||
}, requiredTwoFactor)
|
||||
|
||||
m.Group("/applications", func() {
|
||||
// oauth2 applications
|
||||
|
|
|
@ -17,6 +17,7 @@ import (
|
|||
"syscall"
|
||||
"time"
|
||||
|
||||
"forgejo.org/models/auth"
|
||||
user_model "forgejo.org/models/user"
|
||||
"forgejo.org/modules/base"
|
||||
"forgejo.org/modules/httplib"
|
||||
|
@ -129,6 +130,21 @@ func (ctx *Context) RenderWithErr(msg any, tpl base.TplName, form any) {
|
|||
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.
|
||||
func (ctx *Context) NotFound(logMsg string, logErr error) {
|
||||
ctx.notFoundInternal(logMsg, logErr)
|
||||
|
@ -156,6 +172,7 @@ func (ctx *Context) notFoundInternal(logMsg string, logErr error) {
|
|||
return
|
||||
}
|
||||
|
||||
ctx.validateTwoFactorRequirement()
|
||||
ctx.Data["IsRepo"] = ctx.Repo.Repository != nil
|
||||
ctx.Data["Title"] = ctx.Locale.TrString("error.not_found.title")
|
||||
ctx.HTML(http.StatusNotFound, tplStatus404)
|
||||
|
@ -181,6 +198,7 @@ func (ctx *Context) serverErrorInternal(logMsg string, logErr error) {
|
|||
}
|
||||
}
|
||||
|
||||
ctx.validateTwoFactorRequirement()
|
||||
ctx.HTML(http.StatusInternalServerError, tplStatus500)
|
||||
}
|
||||
|
||||
|
|
|
@ -97,7 +97,7 @@ func determineAccessMode(ctx *Base, pkg *Package, doer *user_model.User) (perm.A
|
|||
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
|
||||
}
|
||||
|
||||
|
|
|
@ -51,6 +51,16 @@
|
|||
</dl>
|
||||
</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">
|
||||
{{ctx.Locale.Tr "admin.config.ssh_config"}}
|
||||
</h4>
|
||||
|
|
|
@ -24,7 +24,7 @@
|
|||
</div>
|
||||
|
||||
<!-- navbar links non-mobile -->
|
||||
{{if and .IsSigned .MustChangePassword}}
|
||||
{{if and .IsSigned .HideNavbarLinks}}
|
||||
{{/* No links */}}
|
||||
{{else if .IsSigned}}
|
||||
{{if not .UnitIssuesGlobalDisabled}}
|
||||
|
@ -54,7 +54,7 @@
|
|||
|
||||
<!-- the full dropdown menus -->
|
||||
<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"}}">
|
||||
<span class="text tw-flex tw-items-center">
|
||||
{{ctx.AvatarUtils.Avatar .SignedUser 24 "tw-mr-1"}}
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
{{if not .HideNavbarLinks}}
|
||||
<div class="flex-container-nav">
|
||||
<div class="ui fluid vertical menu">
|
||||
<div class="header item">{{ctx.Locale.Tr "user.settings"}}</div>
|
||||
|
@ -61,3 +62,4 @@
|
|||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
|
|
@ -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">
|
||||
{{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/webauthn" .}}
|
||||
{{if not .MustEnableTwoFactor}}
|
||||
{{template "user/settings/security/accountlinks" .}}
|
||||
{{if .EnableOpenIDSignIn}}
|
||||
{{template "user/settings/security/openid" .}}
|
||||
{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
{{template "user/settings/layout_footer" .}}
|
||||
|
|
|
@ -5,16 +5,24 @@
|
|||
<p>{{ctx.Locale.Tr "settings.twofa_desc"}}</p>
|
||||
{{if .TOTPEnrolled}}
|
||||
<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">
|
||||
{{.CsrfTokenHtml}}
|
||||
<p>{{ctx.Locale.Tr "settings.regenerate_scratch_token_desc"}}</p>
|
||||
<button class="ui primary button">{{ctx.Locale.Tr "settings.twofa_scratch_token_regenerate"}}</button>
|
||||
</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">
|
||||
{{.CsrfTokenHtml}}
|
||||
<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>
|
||||
</form>
|
||||
{{end}}
|
||||
{{else}}
|
||||
{{/* The recovery tip is there as a means of encouraging a user to enroll */}}
|
||||
<p>{{ctx.Locale.Tr "settings.twofa_recovery_tip"}}</p>
|
||||
|
|
|
@ -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">
|
||||
<h4 class="ui top attached header">
|
||||
{{if .ReenrollTwofa}}
|
||||
{{ctx.Locale.Tr "settings.twofa_reenroll"}}
|
||||
{{else}}
|
||||
{{ctx.Locale.Tr "settings.twofa_enroll"}}
|
||||
{{end}}
|
||||
</h4>
|
||||
<div class="ui attached segment">
|
||||
<p>{{ctx.Locale.Tr "settings.scan_this_image"}}</p>
|
||||
|
|
|
@ -21,11 +21,13 @@ import (
|
|||
user_model "forgejo.org/models/user"
|
||||
"forgejo.org/modules/setting"
|
||||
api "forgejo.org/modules/structs"
|
||||
"forgejo.org/modules/test"
|
||||
"forgejo.org/modules/util"
|
||||
packages_service "forgejo.org/services/packages"
|
||||
packages_cleanup_service "forgejo.org/services/packages/cleanup"
|
||||
"forgejo.org/tests"
|
||||
|
||||
"github.com/pquerna/otp/totp"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"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)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
|
|
@ -5,12 +5,19 @@ package integration
|
|||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
asymkey_model "forgejo.org/models/asymkey"
|
||||
"forgejo.org/models/auth"
|
||||
"forgejo.org/models/perm"
|
||||
"forgejo.org/models/unittest"
|
||||
user_model "forgejo.org/models/user"
|
||||
"forgejo.org/modules/private"
|
||||
"forgejo.org/modules/setting"
|
||||
"forgejo.org/modules/test"
|
||||
"forgejo.org/tests"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
@ -152,3 +159,90 @@ func TestAPIPrivateServ(t *testing.T) {
|
|||
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)
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
|
|
@ -4,7 +4,9 @@
|
|||
package integration
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
|
@ -12,6 +14,9 @@ import (
|
|||
"forgejo.org/models/db"
|
||||
"forgejo.org/models/unittest"
|
||||
user_model "forgejo.org/models/user"
|
||||
"forgejo.org/modules/setting"
|
||||
"forgejo.org/modules/test"
|
||||
"forgejo.org/modules/translation"
|
||||
"forgejo.org/tests"
|
||||
|
||||
"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)
|
||||
}
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
|
|
@ -18,9 +18,17 @@ import (
|
|||
// error pages sometimes which can be hard to reach otherwise.
|
||||
// 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) {
|
||||
defer test.MockVariableValue(&setting.IsProd, false)()
|
||||
defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())()
|
||||
defer enableDevtest()()
|
||||
|
||||
t.Run("Server error", func(t *testing.T) {
|
||||
// `/devtest/error/x` returns 500 for any x by default.
|
||||
|
|
|
@ -76,17 +76,28 @@ func (doc *HTMLDoc) Find(selector string) *goquery.Selection {
|
|||
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
|
||||
func (doc *HTMLDoc) GetCSRF() string {
|
||||
return doc.GetInputValueByName("_csrf")
|
||||
}
|
||||
|
||||
// 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) {
|
||||
sel := doc.doc.Find(selector)
|
||||
// AssertSelection check if selection exists or does not exist depending on checkExists
|
||||
func (doc *HTMLDoc) AssertSelection(t testing.TB, selection *goquery.Selection, checkExists bool) {
|
||||
if checkExists {
|
||||
assert.Equal(t, 1, sel.Length())
|
||||
assert.Equal(t, 1, selection.Length())
|
||||
} 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)
|
||||
}
|
||||
|
|
|
@ -33,6 +33,7 @@ import (
|
|||
user_model "forgejo.org/models/user"
|
||||
"forgejo.org/modules/graceful"
|
||||
"forgejo.org/modules/json"
|
||||
"forgejo.org/modules/keying"
|
||||
"forgejo.org/modules/log"
|
||||
"forgejo.org/modules/setting"
|
||||
"forgejo.org/modules/testlogger"
|
||||
|
@ -50,6 +51,7 @@ import (
|
|||
"github.com/markbates/goth/gothic"
|
||||
goth_github "github.com/markbates/goth/providers/github"
|
||||
goth_gitlab "github.com/markbates/goth/providers/gitlab"
|
||||
"github.com/pquerna/otp/totp"
|
||||
"github.com/santhosh-tekuri/jsonschema/v6"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
@ -276,6 +278,30 @@ func (s *TestSession) MakeRequestNilResponseHashSumRecorder(t testing.TB, rw *Re
|
|||
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"
|
||||
|
||||
func emptyTestSession(t testing.TB) *TestSession {
|
||||
|
@ -418,6 +444,40 @@ func loginUserWithPasswordRemember(t testing.TB, userName, password string, reme
|
|||
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
|
||||
var tokenCounter int64
|
||||
|
||||
|
|
|
@ -14,6 +14,7 @@ import (
|
|||
user_model "forgejo.org/models/user"
|
||||
"forgejo.org/modules/setting"
|
||||
"forgejo.org/modules/test"
|
||||
"forgejo.org/modules/translation"
|
||||
"forgejo.org/tests"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
@ -156,6 +157,78 @@ func TestSettingSecurityAuthSource(t *testing.T) {
|
|||
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) {
|
||||
defer tests.PrepareTestEnv(t)()
|
||||
|
||||
|
|
|
@ -5,11 +5,13 @@
|
|||
package integration
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"forgejo.org/models/auth"
|
||||
"forgejo.org/models/unittest"
|
||||
user_model "forgejo.org/models/user"
|
||||
"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)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
|
|
@ -853,32 +853,6 @@ func TestUserTOTPEnrolled(t *testing.T) {
|
|||
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
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) {
|
||||
defer tests.PrintCurrentTest(t)()
|
||||
|
||||
|
@ -891,7 +865,8 @@ func TestUserTOTPEnrolled(t *testing.T) {
|
|||
called = true
|
||||
})()
|
||||
|
||||
enrollTOTP(t)
|
||||
session.EnrollTOTP(t)
|
||||
unittest.AssertSuccessfulDelete(t, &auth_model.TwoFactor{UID: user.ID})
|
||||
|
||||
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"})
|
||||
enrollTOTP(t)
|
||||
session.EnrollTOTP(t)
|
||||
unittest.AssertSuccessfulDelete(t, &auth_model.TwoFactor{UID: user.ID})
|
||||
|
||||
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) {
|
||||
defer tests.PrepareTestEnv(t)()
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue