Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

(Discontinued) Enforce two-factor authentication #16880

Closed
wants to merge 20 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions custom/conf/app.example.ini
Original file line number Diff line number Diff line change
Expand Up @@ -451,6 +451,10 @@ INTERNAL_TOKEN=
;; Cache successful token hashes. API tokens are stored in the DB as pbkdf2 hashes however, this means that there is a potentially significant hashing load when there are multiple API operations.
;; This cache will store the successfully hashed tokens in a LRU cache as a balance between performance and security.
;SUCCESSFUL_TOKENS_CACHE_SIZE = 20
;;
;; Force users to enroll into Two-Factor Authentication. Users without 2FA have no access to any repositories.
;ENFORCE_TWO_FACTOR_AUTH = false


;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
Expand Down
1 change: 1 addition & 0 deletions docs/content/doc/advanced/config-cheat-sheet.en-us.md
Original file line number Diff line number Diff line change
Expand Up @@ -533,6 +533,7 @@ Certain queues have defaults that override the defaults set in `[queue]` (this o
- off - do not check password complexity
- `PASSWORD_CHECK_PWN`: **false**: Check [HaveIBeenPwned](https://haveibeenpwned.com/Passwords) to see if a password has been exposed.
- `SUCCESSFUL_TOKENS_CACHE_SIZE`: **20**: Cache successful token hashes. API tokens are stored in the DB as pbkdf2 hashes however, this means that there is a potentially significant hashing load when there are multiple API operations. This cache will store the successfully hashed tokens in a LRU cache as a balance between performance and security.
- `ENFORCE_TWO_FACTOR_AUTH`: **false**: Force users to enroll into Two-Factor Authentication. Users without 2FA have no access to any repositories.

## Camo (`camo`)

Expand Down
7 changes: 7 additions & 0 deletions models/perm/access/access.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,13 @@ import (
"context"
"fmt"

"code.gitea.io/gitea/models/auth"
"code.gitea.io/gitea/models/db"
"code.gitea.io/gitea/models/organization"
"code.gitea.io/gitea/models/perm"
repo_model "code.gitea.io/gitea/models/repo"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/setting"
)

// Access represents the highest access level of a user to the repository. The only access type
Expand All @@ -36,6 +38,11 @@ func accessLevel(ctx context.Context, user *user_model.User, repo *repo_model.Re
restricted := false

if user != nil {
if setting.EnforceTwoFactorAuth {
if twoFactor, _ := auth.GetTwoFactorByUID(user.ID); twoFactor == nil {
return perm.AccessModeNone, nil
}
}
userID = user.ID
restricted = user.IsRestricted
}
Expand Down
29 changes: 29 additions & 0 deletions models/perm/access/access_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/setting"

"github.com/stretchr/testify/assert"
)
Expand All @@ -22,6 +23,7 @@ func TestAccessLevel(t *testing.T) {

user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
user5 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 5})
user24 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 24})
user29 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 29})
// A public repository owned by User 2
repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
Expand Down Expand Up @@ -66,13 +68,27 @@ func TestAccessLevel(t *testing.T) {
level, err = access_model.AccessLevel(user29, repo24)
assert.NoError(t, err)
assert.Equal(t, perm_model.AccessModeRead, level)

// test enforced two-factor authentication
setting.EnforceTwoFactorAuth = true
{
level, err = access_model.AccessLevel(user2, repo1)
assert.NoError(t, err)
assert.Equal(t, perm_model.AccessModeNone, level)

level, err = access_model.AccessLevel(user24, repo1)
assert.NoError(t, err)
assert.Equal(t, perm_model.AccessModeRead, level)
}
setting.EnforceTwoFactorAuth = false
}

func TestHasAccess(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())

user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 5})
user24 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 24})
// A public repository owned by User 2
repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
assert.False(t, repo1.IsPrivate)
Expand All @@ -92,6 +108,19 @@ func TestHasAccess(t *testing.T) {

_, err = access_model.HasAccess(db.DefaultContext, user2.ID, repo2)
assert.NoError(t, err)

// test enforced two-factor authentication
setting.EnforceTwoFactorAuth = true
{
has, err = access_model.HasAccess(db.DefaultContext, user1.ID, repo1)
assert.NoError(t, err)
assert.False(t, has)

has, err = access_model.HasAccess(db.DefaultContext, user24.ID, repo1)
assert.NoError(t, err)
assert.True(t, has)
}
setting.EnforceTwoFactorAuth = false
}

func TestRepository_RecalculateAccesses(t *testing.T) {
Expand Down
9 changes: 9 additions & 0 deletions models/perm/access/repo_permission.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,15 @@ import (
"context"
"fmt"

"code.gitea.io/gitea/models/auth"
"code.gitea.io/gitea/models/db"
"code.gitea.io/gitea/models/organization"
perm_model "code.gitea.io/gitea/models/perm"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unit"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting"
)

// Permission contains all the permissions related variables to a repository for a user
Expand Down Expand Up @@ -168,6 +170,13 @@ func GetUserRepoPermission(ctx context.Context, repo *repo_model.Repository, use
return
}

if user != nil && setting.EnforceTwoFactorAuth {
if twoFactor, _ := auth.GetTwoFactorByUID(user.ID); twoFactor == nil {
perm.AccessMode = perm_model.AccessModeNone
return
}
}

var is bool
if user != nil {
is, err = repo_model.IsCollaborator(ctx, repo.ID, user.ID)
Expand Down
4 changes: 4 additions & 0 deletions modules/context/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -768,6 +768,10 @@ func Contexter(ctx context.Context) func(next http.Handler) http.Handler {
ctx.Data["ShowFooterBranding"] = setting.ShowFooterBranding
ctx.Data["ShowFooterVersion"] = setting.ShowFooterVersion

ctx.Data["ShowTwoFactorRequiredMessage"] = setting.EnforceTwoFactorAuth &&
ctx.Session.Get(auth.SessionKeyUID) != nil &&
ctx.Session.Get(auth.SessionKeyTwofaAuthed) == nil

ctx.Data["EnableSwagger"] = setting.API.EnableSwagger
ctx.Data["EnableOpenIDSignIn"] = setting.Service.EnableOpenIDSignIn
ctx.Data["DisableMigrations"] = setting.Repository.DisableMigrations
Expand Down
2 changes: 2 additions & 0 deletions modules/setting/setting.go
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,7 @@ var (
PasswordHashAlgo string
PasswordCheckPwn bool
SuccessfulTokensCacheSize int
EnforceTwoFactorAuth bool

Camo = struct {
Enabled bool
Expand Down Expand Up @@ -962,6 +963,7 @@ func loadFromConf(allowEmpty bool, extraConfig string) {
CSRFCookieHTTPOnly = sec.Key("CSRF_COOKIE_HTTP_ONLY").MustBool(true)
PasswordCheckPwn = sec.Key("PASSWORD_CHECK_PWN").MustBool(false)
SuccessfulTokensCacheSize = sec.Key("SUCCESSFUL_TOKENS_CACHE_SIZE").MustInt(20)
EnforceTwoFactorAuth = sec.Key("ENFORCE_TWO_FACTOR_AUTH").MustBool(false)

InternalToken = loadSecret(sec, "INTERNAL_TOKEN_URI", "INTERNAL_TOKEN")
if InstallLock && InternalToken == "" {
Expand Down
1 change: 1 addition & 0 deletions options/locale/locale_en-US.ini
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,7 @@ use_scratch_code = Use a scratch code
twofa_scratch_used = You have used your scratch code. You have been redirected to the two-factor settings page so you may remove your device enrollment or generate a new scratch code.
twofa_passcode_incorrect = Your passcode is incorrect. If you misplaced your device, use your scratch code to sign in.
twofa_scratch_token_incorrect = Your scratch code is incorrect.
twofa_required = You must setup Two-Factor Authentication to get access to repositories
login_userpass = Sign In
login_openid = OpenID
oauth_signup_tab = Register New Account
Expand Down
12 changes: 12 additions & 0 deletions routers/web/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,11 @@ func AutoSignIn(ctx *context.Context) (bool, error) {
if err := ctx.Session.Set("uname", u.Name); err != nil {
return false, err
}
if twofa, _ := auth.GetTwoFactorByUID(u.ID); twofa != nil {
if err := ctx.Session.Set(auth_service.SessionKeyTwofaAuthed, true); err != nil {
return false, err
}
}
if err := ctx.Session.Release(); err != nil {
return false, err
}
Expand Down Expand Up @@ -313,6 +318,8 @@ func handleSignInFull(ctx *context.Context, u *user_model.User, remember, obeyRe
return setting.AppSubURL + "/"
}

isTwofaAuthed := ctx.Session.Get("twofaUid") != nil

// Delete the openid, 2fa and linkaccount data
_ = ctx.Session.Delete("openid_verified_uri")
_ = ctx.Session.Delete("openid_signin_remember")
Expand All @@ -327,6 +334,11 @@ func handleSignInFull(ctx *context.Context, u *user_model.User, remember, obeyRe
if err := ctx.Session.Set("uname", u.Name); err != nil {
log.Error("Error setting uname %s session: %v", u.Name, err)
}
if isTwofaAuthed {
if err := ctx.Session.Set(auth_service.SessionKeyTwofaAuthed, true); err != nil {
log.Error("Error setting %s session: %v", auth_service.SessionKeyTwofaAuthed, err)
}
}
if err := ctx.Session.Release(); err != nil {
log.Error("Unable to store session: %v", err)
}
Expand Down
14 changes: 14 additions & 0 deletions routers/web/user/setting/security/2fa.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import (
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/web"
auth_service "code.gitea.io/gitea/services/auth"
"code.gitea.io/gitea/services/forms"

"github.com/pquerna/otp"
Expand Down Expand Up @@ -145,12 +146,21 @@ func twofaGenerateSecretAndQr(ctx *context.Context) bool {
func EnrollTwoFactor(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("settings")
ctx.Data["PageIsSettingsSecurity"] = true
ctx.Data["ShowTwoFactorRequiredMessage"] = false

t, err := auth.GetTwoFactorByUID(ctx.Doer.ID)
if t != nil {
// already enrolled - we should redirect back!
log.Warn("Trying to re-enroll %-v in twofa when already enrolled", ctx.Doer)
ctx.Flash.Error(ctx.Tr("settings.twofa_is_enrolled"))

if ctx.Session.Get(auth_service.SessionKeyTwofaAuthed) == nil {
// in case a 2FA user is using an old session (the session doesn't know 2FA authed),
// he will be navigated to this page, we should update the session status
_ = ctx.Session.Set(auth_service.SessionKeyTwofaAuthed, true)
_ = ctx.Session.Release()
}

ctx.Redirect(setting.AppSubURL + "/user/settings/security")
return
}
Expand All @@ -171,6 +181,7 @@ func EnrollTwoFactorPost(ctx *context.Context) {
form := web.GetForm(ctx).(*forms.TwoFactorAuthForm)
ctx.Data["Title"] = ctx.Tr("settings")
ctx.Data["PageIsSettingsSecurity"] = true
ctx.Data["ShowTwoFactorRequiredMessage"] = false

t, err := auth.GetTwoFactorByUID(ctx.Doer.ID)
if t != nil {
Expand Down Expand Up @@ -233,6 +244,9 @@ func EnrollTwoFactorPost(ctx *context.Context) {
// tolerate this failure - it's more important to continue
log.Error("Unable to delete twofaUri from the session: Error: %v", err)
}
if err := ctx.Session.Set(auth_service.SessionKeyTwofaAuthed, true); err != nil {
log.Error("Unable to set %s to session: Error: %v", auth_service.SessionKeyTwofaAuthed, err)
}
if err := ctx.Session.Release(); err != nil {
// tolerate this failure - it's more important to continue
log.Error("Unable to save changes to the session: %v", err)
Expand Down
7 changes: 7 additions & 0 deletions services/auth/session.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,13 @@ import (
"code.gitea.io/gitea/modules/log"
)

// The session keys used by different packages (in the future ...)
const (
SessionKeyUID = "uid"
SessionKeyUname = "uname"
SessionKeyTwofaAuthed = "twofaAuthed"
)

// Ensure the struct implements the interface.
var (
_ Method = &Session{}
Expand Down
5 changes: 5 additions & 0 deletions templates/base/alert.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,8 @@
<p>{{.Flash.WarningMsg | Str2html}}</p>
</div>
{{end}}
{{if .ShowTwoFactorRequiredMessage}}
<div class="ui negative message flash-error">
<p><a href="{{AppSubUrl}}/user/settings/security/two_factor/enroll">{{.locale.Tr "auth.twofa_required"}} &raquo;</a></p>
</div>
{{end}}
11 changes: 10 additions & 1 deletion templates/status/404.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,19 @@
<div class="page-content ui container center full-screen-width {{if .IsRepo}}repository{{end}}">
{{if .IsRepo}}{{template "repo/header" .}}{{end}}
<div class="ui container center">
{{if .ShowTwoFactorRequiredMessage}}
<div class="ui negative message flash-error">
<p><a href="{{AppSubUrl}}/user/settings/security/two_factor/enroll">{{.locale.Tr "auth.twofa_required"}} &raquo;</a></p>
</div>
{{end}}
<p style="margin-top: 100px"><img src="{{AssetUrlPrefix}}/img/404.png" alt="404"/></p>
<div class="ui divider"></div>
<br>
<p>{{.locale.Tr "error404" | Safe}}
<p>
{{.locale.Tr "error404" | Safe}}
{{/* make a clear guide to tell a anonymous user should try to sign-in to access the repository, otherwise a 404 page may confuse a user who hasn't signed-in */}}
{{if not .IsSigned}}<a href="{{AppSubUrl}}/user/forget_password">{{.locale.Tr "sign_in"}} &raquo;</a>{{end}}
</p>
{{if .ShowFooterVersion}}<p>{{.locale.Tr "admin.config.app_ver"}}: {{AppVer}}</p>{{end}}
</div>
</div>
Expand Down