Skip to content

Commit 951309f

Browse files
jonasfranzlafriks
authored andcommitted
Add support for FIDO U2F (#3971)
* Add support for U2F Signed-off-by: Jonas Franz <[email protected]> * Add vendor library Add missing translations Signed-off-by: Jonas Franz <[email protected]> * Minor improvements Signed-off-by: Jonas Franz <[email protected]> * Add U2F support for Firefox, Chrome (Android) by introducing a custom JS library Add U2F error handling Signed-off-by: Jonas Franz <[email protected]> * Add U2F login page to OAuth Signed-off-by: Jonas Franz <[email protected]> * Move U2F user settings to a separate file Signed-off-by: Jonas Franz <[email protected]> * Add unit tests for u2f model Renamed u2f table name Signed-off-by: Jonas Franz <[email protected]> * Fix problems caused by refactoring Signed-off-by: Jonas Franz <[email protected]> * Add U2F documentation Signed-off-by: Jonas Franz <[email protected]> * Remove not needed console.log-s Signed-off-by: Jonas Franz <[email protected]> * Add default values to app.ini.sample Add FIDO U2F to comparison Signed-off-by: Jonas Franz <[email protected]>
1 parent f933bcd commit 951309f

File tree

34 files changed

+1599
-9
lines changed

34 files changed

+1599
-9
lines changed

custom/conf/app.ini.sample

+9-1
Original file line numberDiff line numberDiff line change
@@ -288,7 +288,7 @@ RESET_PASSWD_CODE_LIVE_MINUTES = 180
288288
REGISTER_EMAIL_CONFIRM = false
289289
; Disallow registration, only allow admins to create accounts.
290290
DISABLE_REGISTRATION = false
291-
; Allow registration only using third part services, it works only when DISABLE_REGISTRATION is false
291+
; Allow registration only using third part services, it works only when DISABLE_REGISTRATION is false
292292
ALLOW_ONLY_EXTERNAL_REGISTRATION = false
293293
; User must sign in to view anything.
294294
REQUIRE_SIGNIN_VIEW = false
@@ -570,6 +570,14 @@ MAX_RESPONSE_ITEMS = 50
570570
LANGS = en-US,zh-CN,zh-HK,zh-TW,de-DE,fr-FR,nl-NL,lv-LV,ru-RU,ja-JP,es-ES,pt-BR,pl-PL,bg-BG,it-IT,fi-FI,tr-TR,cs-CZ,sr-SP,sv-SE,ko-KR
571571
NAMES = English,简体中文,繁體中文(香港),繁體中文(台灣),Deutsch,français,Nederlands,latviešu,русский,日本語,español,português do Brasil,polski,български,italiano,suomi,Türkçe,čeština,српски,svenska,한국어
572572

573+
[U2F]
574+
; Two Factor authentication with security keys
575+
; https://developers.yubico.com/U2F/App_ID.html
576+
APP_ID = %(PROTOCOL)s://%(DOMAIN)s:%(HTTP_PORT)s
577+
; Comma seperated list of truisted facets
578+
TRUSTED_FACETS = %(PROTOCOL)s://%(DOMAIN)s:%(HTTP_PORT)s
579+
580+
573581
; Used for datetimepicker
574582
[i18n.datelang]
575583
en-US = en

docs/content/doc/advanced/config-cheat-sheet.en-us.md

+4
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,10 @@ Values containing `#` or `;` must be quoted using `` ` `` or `"""`.
272272
- `MAX_GIT_DIFF_FILES`: **100**: Max number of files shown in diff view.
273273
- `GC_ARGS`: **\<empty\>**: Arguments for command `git gc`, e.g. `--aggressive --auto`.
274274

275+
## U2F (`U2F`)
276+
- `APP_ID`: **`ROOT_URL`**: Declares the facet of the application. Requires HTTPS.
277+
- `TRUSTED_FACETS`: List of additional facets which are trusted. This is not support by all browsers.
278+
275279
## Markup (`markup`)
276280

277281
Gitea can support Markup using external tools. The example below will add a markup named `asciidoc`.

docs/content/doc/features/comparison.en-us.md

+9
Original file line numberDiff line numberDiff line change
@@ -535,6 +535,15 @@ _Symbols used in table:_
535535
<td>✓</td>
536536
<td>✓</td>
537537
</tr>
538+
<tr>
539+
<td>FIDO U2F (2FA)</td>
540+
<td>✓</td>
541+
<td>✘</td>
542+
<td>✓</td>
543+
<td>✓</td>
544+
<td>✓</td>
545+
<td>✓</td>
546+
</tr>
538547
<tr>
539548
<td>Webhook support</td>
540549
<td>✓</td>

models/error.go

+22
Original file line numberDiff line numberDiff line change
@@ -1237,3 +1237,25 @@ func IsErrExternalLoginUserNotExist(err error) bool {
12371237
func (err ErrExternalLoginUserNotExist) Error() string {
12381238
return fmt.Sprintf("external login user link does not exists [userID: %d, loginSourceID: %d]", err.UserID, err.LoginSourceID)
12391239
}
1240+
1241+
// ____ ________________________________ .__ __ __ .__
1242+
// | | \_____ \_ _____/\______ \ ____ ____ |__| _______/ |_____________ _/ |_|__| ____ ____
1243+
// | | // ____/| __) | _// __ \ / ___\| |/ ___/\ __\_ __ \__ \\ __\ |/ _ \ / \
1244+
// | | // \| \ | | \ ___// /_/ > |\___ \ | | | | \// __ \| | | ( <_> ) | \
1245+
// |______/ \_______ \___ / |____|_ /\___ >___ /|__/____ > |__| |__| (____ /__| |__|\____/|___| /
1246+
// \/ \/ \/ \/_____/ \/ \/ \/
1247+
1248+
// ErrU2FRegistrationNotExist represents a "ErrU2FRegistrationNotExist" kind of error.
1249+
type ErrU2FRegistrationNotExist struct {
1250+
ID int64
1251+
}
1252+
1253+
func (err ErrU2FRegistrationNotExist) Error() string {
1254+
return fmt.Sprintf("U2F registration does not exist [id: %d]", err.ID)
1255+
}
1256+
1257+
// IsErrU2FRegistrationNotExist checks if an error is a ErrU2FRegistrationNotExist.
1258+
func IsErrU2FRegistrationNotExist(err error) bool {
1259+
_, ok := err.(ErrU2FRegistrationNotExist)
1260+
return ok
1261+
}

models/fixtures/u2f_registration.yml

+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
-
2+
id: 1
3+
name: "U2F Key"
4+
user_id: 1
5+
counter: 0
6+
created_unix: 946684800
7+
updated_unix: 946684800

models/migrations/migrations.go

+2
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,8 @@ var migrations = []Migration{
182182
NewMigration("add language column for user setting", addLanguageSetting),
183183
// v64 -> v65
184184
NewMigration("add multiple assignees", addMultipleAssignees),
185+
// v65 -> v66
186+
NewMigration("add u2f", addU2FReg),
185187
}
186188

187189
// Migrate database to current version

models/migrations/v65.go

+19
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package migrations
2+
3+
import (
4+
"code.gitea.io/gitea/modules/util"
5+
"github.com/go-xorm/xorm"
6+
)
7+
8+
func addU2FReg(x *xorm.Engine) error {
9+
type U2FRegistration struct {
10+
ID int64 `xorm:"pk autoincr"`
11+
Name string
12+
UserID int64 `xorm:"INDEX"`
13+
Raw []byte
14+
Counter uint32
15+
CreatedUnix util.TimeStamp `xorm:"INDEX created"`
16+
UpdatedUnix util.TimeStamp `xorm:"INDEX updated"`
17+
}
18+
return x.Sync2(&U2FRegistration{})
19+
}

models/models.go

+1
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,7 @@ func init() {
120120
new(LFSLock),
121121
new(Reaction),
122122
new(IssueAssignees),
123+
new(U2FRegistration),
123124
)
124125

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

models/u2f.go

+120
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
// Copyright 2018 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+
"code.gitea.io/gitea/modules/log"
9+
"code.gitea.io/gitea/modules/util"
10+
11+
"github.com/tstranex/u2f"
12+
)
13+
14+
// U2FRegistration represents the registration data and counter of a security key
15+
type U2FRegistration struct {
16+
ID int64 `xorm:"pk autoincr"`
17+
Name string
18+
UserID int64 `xorm:"INDEX"`
19+
Raw []byte
20+
Counter uint32
21+
CreatedUnix util.TimeStamp `xorm:"INDEX created"`
22+
UpdatedUnix util.TimeStamp `xorm:"INDEX updated"`
23+
}
24+
25+
// TableName returns a better table name for U2FRegistration
26+
func (reg U2FRegistration) TableName() string {
27+
return "u2f_registration"
28+
}
29+
30+
// Parse will convert the db entry U2FRegistration to an u2f.Registration struct
31+
func (reg *U2FRegistration) Parse() (*u2f.Registration, error) {
32+
r := new(u2f.Registration)
33+
return r, r.UnmarshalBinary(reg.Raw)
34+
}
35+
36+
func (reg *U2FRegistration) updateCounter(e Engine) error {
37+
_, err := e.ID(reg.ID).Cols("counter").Update(reg)
38+
return err
39+
}
40+
41+
// UpdateCounter will update the database value of counter
42+
func (reg *U2FRegistration) UpdateCounter() error {
43+
return reg.updateCounter(x)
44+
}
45+
46+
// U2FRegistrationList is a list of *U2FRegistration
47+
type U2FRegistrationList []*U2FRegistration
48+
49+
// ToRegistrations will convert all U2FRegistrations to u2f.Registrations
50+
func (list U2FRegistrationList) ToRegistrations() []u2f.Registration {
51+
regs := make([]u2f.Registration, len(list))
52+
for _, reg := range list {
53+
r, err := reg.Parse()
54+
if err != nil {
55+
log.Fatal(4, "parsing u2f registration: %v", err)
56+
continue
57+
}
58+
regs = append(regs, *r)
59+
}
60+
61+
return regs
62+
}
63+
64+
func getU2FRegistrationsByUID(e Engine, uid int64) (U2FRegistrationList, error) {
65+
regs := make(U2FRegistrationList, 0)
66+
return regs, e.Where("user_id = ?", uid).Find(&regs)
67+
}
68+
69+
// GetU2FRegistrationByID returns U2F registration by id
70+
func GetU2FRegistrationByID(id int64) (*U2FRegistration, error) {
71+
return getU2FRegistrationByID(x, id)
72+
}
73+
74+
func getU2FRegistrationByID(e Engine, id int64) (*U2FRegistration, error) {
75+
reg := new(U2FRegistration)
76+
if found, err := e.ID(id).Get(reg); err != nil {
77+
return nil, err
78+
} else if !found {
79+
return nil, ErrU2FRegistrationNotExist{ID: id}
80+
}
81+
return reg, nil
82+
}
83+
84+
// GetU2FRegistrationsByUID returns all U2F registrations of the given user
85+
func GetU2FRegistrationsByUID(uid int64) (U2FRegistrationList, error) {
86+
return getU2FRegistrationsByUID(x, uid)
87+
}
88+
89+
func createRegistration(e Engine, user *User, name string, reg *u2f.Registration) (*U2FRegistration, error) {
90+
raw, err := reg.MarshalBinary()
91+
if err != nil {
92+
return nil, err
93+
}
94+
r := &U2FRegistration{
95+
UserID: user.ID,
96+
Name: name,
97+
Counter: 0,
98+
Raw: raw,
99+
}
100+
_, err = e.InsertOne(r)
101+
if err != nil {
102+
return nil, err
103+
}
104+
return r, nil
105+
}
106+
107+
// CreateRegistration will create a new U2FRegistration from the given Registration
108+
func CreateRegistration(user *User, name string, reg *u2f.Registration) (*U2FRegistration, error) {
109+
return createRegistration(x, user, name, reg)
110+
}
111+
112+
// DeleteRegistration will delete U2FRegistration
113+
func DeleteRegistration(reg *U2FRegistration) error {
114+
return deleteRegistration(x, reg)
115+
}
116+
117+
func deleteRegistration(e Engine, reg *U2FRegistration) error {
118+
_, err := e.Delete(reg)
119+
return err
120+
}

models/u2f_test.go

+61
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
package models
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/assert"
7+
"github.com/tstranex/u2f"
8+
)
9+
10+
func TestGetU2FRegistrationByID(t *testing.T) {
11+
assert.NoError(t, PrepareTestDatabase())
12+
13+
res, err := GetU2FRegistrationByID(1)
14+
assert.NoError(t, err)
15+
assert.Equal(t, "U2F Key", res.Name)
16+
17+
_, err = GetU2FRegistrationByID(342432)
18+
assert.Error(t, err)
19+
assert.True(t, IsErrU2FRegistrationNotExist(err))
20+
}
21+
22+
func TestGetU2FRegistrationsByUID(t *testing.T) {
23+
assert.NoError(t, PrepareTestDatabase())
24+
25+
res, err := GetU2FRegistrationsByUID(1)
26+
assert.NoError(t, err)
27+
assert.Len(t, res, 1)
28+
assert.Equal(t, "U2F Key", res[0].Name)
29+
}
30+
31+
func TestU2FRegistration_TableName(t *testing.T) {
32+
assert.Equal(t, "u2f_registration", U2FRegistration{}.TableName())
33+
}
34+
35+
func TestU2FRegistration_UpdateCounter(t *testing.T) {
36+
assert.NoError(t, PrepareTestDatabase())
37+
reg := AssertExistsAndLoadBean(t, &U2FRegistration{ID: 1}).(*U2FRegistration)
38+
reg.Counter = 1
39+
assert.NoError(t, reg.UpdateCounter())
40+
AssertExistsIf(t, true, &U2FRegistration{ID: 1, Counter: 1})
41+
}
42+
43+
func TestCreateRegistration(t *testing.T) {
44+
assert.NoError(t, PrepareTestDatabase())
45+
user := AssertExistsAndLoadBean(t, &User{ID: 1}).(*User)
46+
47+
res, err := CreateRegistration(user, "U2F Created Key", &u2f.Registration{Raw: []byte("Test")})
48+
assert.NoError(t, err)
49+
assert.Equal(t, "U2F Created Key", res.Name)
50+
assert.Equal(t, []byte("Test"), res.Raw)
51+
52+
AssertExistsIf(t, true, &U2FRegistration{Name: "U2F Created Key", UserID: user.ID})
53+
}
54+
55+
func TestDeleteRegistration(t *testing.T) {
56+
assert.NoError(t, PrepareTestDatabase())
57+
reg := AssertExistsAndLoadBean(t, &U2FRegistration{ID: 1}).(*U2FRegistration)
58+
59+
assert.NoError(t, DeleteRegistration(reg))
60+
AssertNotExistsBean(t, &U2FRegistration{ID: 1})
61+
}

modules/auth/user_form.go

+20
Original file line numberDiff line numberDiff line change
@@ -211,3 +211,23 @@ type TwoFactorScratchAuthForm struct {
211211
func (f *TwoFactorScratchAuthForm) Validate(ctx *macaron.Context, errs binding.Errors) binding.Errors {
212212
return validate(errs, ctx.Data, f, ctx.Locale)
213213
}
214+
215+
// U2FRegistrationForm for reserving an U2F name
216+
type U2FRegistrationForm struct {
217+
Name string `binding:"Required"`
218+
}
219+
220+
// Validate valideates the fields
221+
func (f *U2FRegistrationForm) Validate(ctx *macaron.Context, errs binding.Errors) binding.Errors {
222+
return validate(errs, ctx.Data, f, ctx.Locale)
223+
}
224+
225+
// U2FDeleteForm for deleting U2F keys
226+
type U2FDeleteForm struct {
227+
ID int64 `binding:"Required"`
228+
}
229+
230+
// Validate valideates the fields
231+
func (f *U2FDeleteForm) Validate(ctx *macaron.Context, errs binding.Errors) binding.Errors {
232+
return validate(errs, ctx.Data, f, ctx.Locale)
233+
}

modules/setting/setting.go

+8
Original file line numberDiff line numberDiff line change
@@ -521,6 +521,11 @@ var (
521521
MaxResponseItems: 50,
522522
}
523523

524+
U2F = struct {
525+
AppID string
526+
TrustedFacets []string
527+
}{}
528+
524529
// I18n settings
525530
Langs []string
526531
Names []string
@@ -1135,6 +1140,9 @@ func NewContext() {
11351140
IsInputFile: sec.Key("IS_INPUT_FILE").MustBool(false),
11361141
})
11371142
}
1143+
sec = Cfg.Section("U2F")
1144+
U2F.TrustedFacets, _ = shellquote.Split(sec.Key("TRUSTED_FACETS").MustString(strings.TrimRight(AppURL, "/")))
1145+
U2F.AppID = sec.Key("APP_ID").MustString(strings.TrimRight(AppURL, "/"))
11381146
}
11391147

11401148
// Service settings

options/locale/locale_en-US.ini

+22
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,19 @@ twofa = Two-Factor Authentication
3131
twofa_scratch = Two-Factor Scratch Code
3232
passcode = Passcode
3333

34+
u2f_insert_key = Insert your security key
35+
u2f_sign_in = Press the button on your security key. If you can't find a button, re-insert it.
36+
u2f_press_button = Please press the button on your security key…
37+
u2f_use_twofa = Use a two-factor code from your phone
38+
u2f_error = We can't read your security key!
39+
u2f_unsupported_browser = Your browser don't support U2F keys. Please try another browser.
40+
u2f_error_1 = An unknown error occured. Please retry.
41+
u2f_error_2 = Please make sure that you're using an encrypted connection (https://) and visiting the correct URL.
42+
u2f_error_3 = The server could not proceed your request.
43+
u2f_error_4 = The presented key is not eligible for this request. If you try to register it, make sure that the key isn't already registered.
44+
u2f_error_5 = Timeout reached before your key could be read. Please reload to retry.
45+
u2f_reload = Reload
46+
3447
repository = Repository
3548
organization = Organization
3649
mirror = Mirror
@@ -320,6 +333,7 @@ twofa = Two-Factor Authentication
320333
account_link = Linked Accounts
321334
organization = Organizations
322335
uid = Uid
336+
u2f = Security Keys
323337

324338
public_profile = Public Profile
325339
profile_desc = Your email address will be used for notifications and other operations.
@@ -449,6 +463,14 @@ then_enter_passcode = And enter the passcode shown in the application:
449463
passcode_invalid = The passcode is incorrect. Try again.
450464
twofa_enrolled = Your account has been enrolled into two-factor authentication. Store your scratch token (%s) in a safe place as it is only shown once!
451465

466+
u2f_desc = Security keys are hardware devices containing cryptograhic keys. They could be used for two factor authentication. The security key must support the <a href="https://fidoalliance.org/">FIDO U2F</a> standard.
467+
u2f_require_twofa = Two-Factor-Authentication must be enrolled in order to use security keys.
468+
u2f_register_key = Add Security Key
469+
u2f_nickname = Nickname
470+
u2f_press_button = Press the button on your security key to register it.
471+
u2f_delete_key = Remove Security Key
472+
u2f_delete_key_desc= If you remove a security key you cannot login with it anymore. Are you sure?
473+
452474
manage_account_links = Manage Linked Accounts
453475
manage_account_links_desc = These external accounts are linked to your Gitea account.
454476
account_links_not_available = There are currently no external accounts linked to your Gitea account.

0 commit comments

Comments
 (0)