Skip to content

Commit 6dd096b

Browse files
minecrafterlunny
authored andcommitted
Two factor authentication support (#630)
* Initial commit for 2FA support Signed-off-by: Andrew <[email protected]> * Add vendored files * Add missing depends * A few clean ups * Added improvements, proper encryption * Better encryption key * Simplify "key" generation * Make 2FA enrollment page more robust * Fix typo * Rename twofa/2FA to TwoFactor * UNIQUE INDEX -> UNIQUE
1 parent 64375d8 commit 6dd096b

40 files changed

+3395
-8
lines changed

Diff for: cmd/web.go

+13
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,12 @@ func runWeb(ctx *cli.Context) error {
203203
m.Post("/sign_up", bindIgnErr(auth.RegisterForm{}), user.SignUpPost)
204204
m.Get("/reset_password", user.ResetPasswd)
205205
m.Post("/reset_password", user.ResetPasswdPost)
206+
m.Group("/two_factor", func() {
207+
m.Get("", user.TwoFactor)
208+
m.Post("", bindIgnErr(auth.TwoFactorAuthForm{}), user.TwoFactorPost)
209+
m.Get("/scratch", user.TwoFactorScratch)
210+
m.Post("/scratch", bindIgnErr(auth.TwoFactorScratchAuthForm{}), user.TwoFactorScratchPost)
211+
})
206212
}, reqSignOut)
207213

208214
m.Group("/user/settings", func() {
@@ -223,6 +229,13 @@ func runWeb(ctx *cli.Context) error {
223229
Post(bindIgnErr(auth.NewAccessTokenForm{}), user.SettingsApplicationsPost)
224230
m.Post("/applications/delete", user.SettingsDeleteApplication)
225231
m.Route("/delete", "GET,POST", user.SettingsDelete)
232+
m.Group("/two_factor", func() {
233+
m.Get("", user.SettingsTwoFactor)
234+
m.Post("/regenerate_scratch", user.SettingsTwoFactorRegenerateScratch)
235+
m.Post("/disable", user.SettingsTwoFactorDisable)
236+
m.Get("/enroll", user.SettingsTwoFactorEnroll)
237+
m.Post("/enroll", bindIgnErr(auth.TwoFactorAuthForm{}), user.SettingsTwoFactorEnrollPost)
238+
})
226239
}, reqSignIn, func(ctx *context.Context) {
227240
ctx.Data["PageIsUserSettings"] = true
228241
})

Diff for: models/error.go

+19
Original file line numberDiff line numberDiff line change
@@ -787,6 +787,25 @@ func (err ErrTeamAlreadyExist) Error() string {
787787
return fmt.Sprintf("team already exists [org_id: %d, name: %s]", err.OrgID, err.Name)
788788
}
789789

790+
//
791+
// Two-factor authentication
792+
//
793+
794+
// ErrTwoFactorNotEnrolled indicates that a user is not enrolled in two-factor authentication.
795+
type ErrTwoFactorNotEnrolled struct {
796+
UID int64
797+
}
798+
799+
// IsErrTwoFactorNotEnrolled checks if an error is a ErrTwoFactorNotEnrolled.
800+
func IsErrTwoFactorNotEnrolled(err error) bool {
801+
_, ok := err.(ErrTwoFactorNotEnrolled)
802+
return ok
803+
}
804+
805+
func (err ErrTwoFactorNotEnrolled) Error() string {
806+
return fmt.Sprintf("user not enrolled in 2FA [uid: %d]", err.UID)
807+
}
808+
790809
// ____ ___ .__ .___
791810
// | | \______ | | _________ __| _/
792811
// | | /\____ \| | / _ \__ \ / __ |

Diff for: models/models.go

+1
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ func init() {
105105
new(Notification),
106106
new(IssueUser),
107107
new(LFSMetaObject),
108+
new(TwoFactor),
108109
)
109110

110111
gonicNames := []string{"SSL", "UID"}

Diff for: models/twofactor.go

+141
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
// Copyright 2017 The Gitea Authors. All rights reserved.
2+
// Use of this source code is governed by a MIT-style
3+
// license that can be found in the LICENSE file.
4+
5+
package models
6+
7+
import (
8+
"crypto/md5"
9+
"crypto/subtle"
10+
"encoding/base64"
11+
"time"
12+
13+
"github.com/Unknwon/com"
14+
"github.com/go-xorm/xorm"
15+
"github.com/pquerna/otp/totp"
16+
17+
"code.gitea.io/gitea/modules/base"
18+
"code.gitea.io/gitea/modules/setting"
19+
)
20+
21+
// TwoFactor represents a two-factor authentication token.
22+
type TwoFactor struct {
23+
ID int64 `xorm:"pk autoincr"`
24+
UID int64 `xorm:"UNIQUE"`
25+
Secret string
26+
ScratchToken string
27+
28+
Created time.Time `xorm:"-"`
29+
CreatedUnix int64 `xorm:"INDEX"`
30+
Updated time.Time `xorm:"-"` // Note: Updated must below Created for AfterSet.
31+
UpdatedUnix int64 `xorm:"INDEX"`
32+
}
33+
34+
// BeforeInsert will be invoked by XORM before inserting a record representing this object.
35+
func (t *TwoFactor) BeforeInsert() {
36+
t.CreatedUnix = time.Now().Unix()
37+
}
38+
39+
// BeforeUpdate is invoked from XORM before updating this object.
40+
func (t *TwoFactor) BeforeUpdate() {
41+
t.UpdatedUnix = time.Now().Unix()
42+
}
43+
44+
// AfterSet is invoked from XORM after setting the value of a field of this object.
45+
func (t *TwoFactor) AfterSet(colName string, _ xorm.Cell) {
46+
switch colName {
47+
case "created_unix":
48+
t.Created = time.Unix(t.CreatedUnix, 0).Local()
49+
case "updated_unix":
50+
t.Updated = time.Unix(t.UpdatedUnix, 0).Local()
51+
}
52+
}
53+
54+
// GenerateScratchToken recreates the scratch token the user is using.
55+
func (t *TwoFactor) GenerateScratchToken() error {
56+
token, err := base.GetRandomString(8)
57+
if err != nil {
58+
return err
59+
}
60+
t.ScratchToken = token
61+
return nil
62+
}
63+
64+
// VerifyScratchToken verifies if the specified scratch token is valid.
65+
func (t *TwoFactor) VerifyScratchToken(token string) bool {
66+
if len(token) == 0 {
67+
return false
68+
}
69+
return subtle.ConstantTimeCompare([]byte(token), []byte(t.ScratchToken)) == 1
70+
}
71+
72+
func (t *TwoFactor) getEncryptionKey() []byte {
73+
k := md5.Sum([]byte(setting.SecretKey))
74+
return k[:]
75+
}
76+
77+
// SetSecret sets the 2FA secret.
78+
func (t *TwoFactor) SetSecret(secret string) error {
79+
secretBytes, err := com.AESEncrypt(t.getEncryptionKey(), []byte(secret))
80+
if err != nil {
81+
return err
82+
}
83+
t.Secret = base64.StdEncoding.EncodeToString(secretBytes)
84+
return nil
85+
}
86+
87+
// ValidateTOTP validates the provided passcode.
88+
func (t *TwoFactor) ValidateTOTP(passcode string) (bool, error) {
89+
decodedStoredSecret, err := base64.StdEncoding.DecodeString(t.Secret)
90+
if err != nil {
91+
return false, err
92+
}
93+
secret, err := com.AESDecrypt(t.getEncryptionKey(), decodedStoredSecret)
94+
if err != nil {
95+
return false, err
96+
}
97+
secretStr := string(secret)
98+
return totp.Validate(passcode, secretStr), nil
99+
}
100+
101+
// NewTwoFactor creates a new two-factor authentication token.
102+
func NewTwoFactor(t *TwoFactor) error {
103+
err := t.GenerateScratchToken()
104+
if err != nil {
105+
return err
106+
}
107+
_, err = x.Insert(t)
108+
return err
109+
}
110+
111+
// UpdateTwoFactor updates a two-factor authentication token.
112+
func UpdateTwoFactor(t *TwoFactor) error {
113+
_, err := x.Id(t.ID).AllCols().Update(t)
114+
return err
115+
}
116+
117+
// GetTwoFactorByUID returns the two-factor authentication token associated with
118+
// the user, if any.
119+
func GetTwoFactorByUID(uid int64) (*TwoFactor, error) {
120+
twofa := &TwoFactor{UID: uid}
121+
has, err := x.Get(twofa)
122+
if err != nil {
123+
return nil, err
124+
} else if !has {
125+
return nil, ErrTwoFactorNotEnrolled{uid}
126+
}
127+
return twofa, nil
128+
}
129+
130+
// DeleteTwoFactorByID deletes two-factor authentication token by given ID.
131+
func DeleteTwoFactorByID(id, userID int64) error {
132+
cnt, err := x.Id(id).Delete(&TwoFactor{
133+
UID: userID,
134+
})
135+
if err != nil {
136+
return err
137+
} else if cnt != 1 {
138+
return ErrTwoFactorNotEnrolled{userID}
139+
}
140+
return nil
141+
}

Diff for: modules/auth/user_form.go

+20
Original file line numberDiff line numberDiff line change
@@ -173,3 +173,23 @@ type NewAccessTokenForm struct {
173173
func (f *NewAccessTokenForm) Validate(ctx *macaron.Context, errs binding.Errors) binding.Errors {
174174
return validate(errs, ctx.Data, f, ctx.Locale)
175175
}
176+
177+
// TwoFactorAuthForm for logging in with 2FA token.
178+
type TwoFactorAuthForm struct {
179+
Passcode string `binding:"Required"`
180+
}
181+
182+
// Validate valideates the fields
183+
func (f *TwoFactorAuthForm) Validate(ctx *macaron.Context, errs binding.Errors) binding.Errors {
184+
return validate(errs, ctx.Data, f, ctx.Locale)
185+
}
186+
187+
// TwoFactorScratchAuthForm for logging in with 2FA scratch token.
188+
type TwoFactorScratchAuthForm struct {
189+
Token string `binding:"Required"`
190+
}
191+
192+
// Validate valideates the fields
193+
func (f *TwoFactorScratchAuthForm) Validate(ctx *macaron.Context, errs binding.Errors) binding.Errors {
194+
return validate(errs, ctx.Data, f, ctx.Locale)
195+
}

Diff for: options/locale/locale_en-US.ini

+27
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@ email = Email
2323
password = Password
2424
re_type = Re-Type
2525
captcha = Captcha
26+
twofa = Two-factor authentication
27+
twofa_scratch = Two-factor scratch code
28+
passcode = Passcode
2629

2730
repository = Repository
2831
organization = Organization
@@ -175,6 +178,12 @@ invalid_code = Sorry, your confirmation code has expired or not valid.
175178
reset_password_helper = Click here to reset your password
176179
password_too_short = Password length cannot be less then %d.
177180
non_local_account = Non-local accounts cannot change passwords through Gitea.
181+
verify = Verify
182+
scratch_code = Scratch code
183+
use_scratch_code = Use a scratch code
184+
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.
185+
twofa_passcode_incorrect = Your passcode is not correct. If you misplaced your device, use your scratch code.
186+
twofa_scratch_token_incorrect = Your scratch code is not correct.
178187

179188
[mail]
180189
activate_account = Please activate your account
@@ -266,6 +275,7 @@ social = Social Accounts
266275
applications = Applications
267276
orgs = Organizations
268277
delete = Delete Account
278+
twofa = Two-Factor Authentication
269279
uid = Uid
270280
271281
public_profile = Public Profile
@@ -351,6 +361,23 @@ access_token_deletion = Personal Access Token Deletion
351361
access_token_deletion_desc = Delete this personal access token will remove all related accesses of application. Do you want to continue?
352362
delete_token_success = Personal access token has been removed successfully! Don't forget to update your application as well.
353363
364+
twofa_desc = Gitea supports two-factor authentication to provide additional security for your account.
365+
twofa_is_enrolled = Your account is <strong>enrolled</strong> into two-factor authentication.
366+
twofa_not_enrolled = Your account is not currently enrolled into two-factor authentication.
367+
twofa_disable = Disable two-factor authentication
368+
twofa_scratch_token_regenerate = Regenerate scratch token
369+
twofa_scratch_token_regenerated = Your scratch token has been regenerated. It is now %s. Keep it in a safe place.
370+
twofa_enroll = Enroll into two-factor authentication
371+
twofa_disable_note = If needed, you can disable two-factor authentication.
372+
twofa_disable_desc = Disabling two-factor authentication will make your account less secure. Are you sure you want to proceed?
373+
regenerate_scratch_token_desc = If you misplaced your scratch token, or had to use it to log in, you can reset it.
374+
twofa_disabled = Two-factor authentication has been disabled.
375+
scan_this_image = Scan this image with your authentication application:
376+
or_enter_secret = Or enter the secret: %s
377+
then_enter_passcode = Then enter the passcode the application gives you:
378+
passcode_invalid = That passcode is invalid. Try again.
379+
twofa_enrolled = Your account has now been enrolled in two-factor authentication. Make sure to save your scratch token (%s), as it will only be shown once!
380+
354381
delete_account = Delete Your Account
355382
delete_prompt = The operation will delete your account permanently, and <strong>CANNOT</strong> be undone!
356383
confirm_delete_account = Confirm Deletion

0 commit comments

Comments
 (0)