Skip to content

Commit c6e4bc5

Browse files
jolheisermrsdizziezeripath
authored
Check passwords against HaveIBeenPwned (#12716)
* Implement pwn Signed-off-by: jolheiser <[email protected]> * Update module Signed-off-by: jolheiser <[email protected]> * Apply suggestions mrsdizzie Co-authored-by: mrsdizzie <[email protected]> * Add link to HIBP Signed-off-by: jolheiser <[email protected]> * Add more details to admin command Signed-off-by: jolheiser <[email protected]> * Add context to pwn Signed-off-by: jolheiser <[email protected]> * Consistency and making some noise ;) Signed-off-by: jolheiser <[email protected]> Co-authored-by: mrsdizzie <[email protected]> Co-authored-by: zeripath <[email protected]>
1 parent bea343c commit c6e4bc5

File tree

22 files changed

+309
-8
lines changed

22 files changed

+309
-8
lines changed

cmd/admin.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
package cmd
77

88
import (
9+
"context"
910
"errors"
1011
"fmt"
1112
"os"
@@ -265,6 +266,13 @@ func runChangePassword(c *cli.Context) error {
265266
if !pwd.IsComplexEnough(c.String("password")) {
266267
return errors.New("Password does not meet complexity requirements")
267268
}
269+
pwned, err := pwd.IsPwned(context.Background(), c.String("password"))
270+
if err != nil {
271+
return err
272+
}
273+
if pwned {
274+
return errors.New("The password you chose is on a list of stolen passwords previously exposed in public data breaches. Please try again with a different password.\nFor more details, see https://haveibeenpwned.com/Passwords")
275+
}
268276
uname := c.String("username")
269277
user, err := models.GetUserByName(uname)
270278
if err != nil {

custom/conf/app.example.ini

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -433,7 +433,7 @@ REPO_INDEXER_TYPE = bleve
433433
; Index file used for code search.
434434
REPO_INDEXER_PATH = indexers/repos.bleve
435435
; Code indexer connection string, available when `REPO_INDEXER_TYPE` is elasticsearch. i.e. http://elastic:changeme@localhost:9200
436-
REPO_INDEXER_CONN_STR =
436+
REPO_INDEXER_CONN_STR =
437437
; Code indexer name, available when `REPO_INDEXER_TYPE` is elasticsearch
438438
REPO_INDEXER_NAME = gitea_codes
439439

@@ -512,6 +512,8 @@ PASSWORD_COMPLEXITY = off
512512
PASSWORD_HASH_ALGO = argon2
513513
; Set false to allow JavaScript to read CSRF cookie
514514
CSRF_COOKIE_HTTP_ONLY = true
515+
; Validate against https://haveibeenpwned.com/Passwords to see if a password has been exposed
516+
PASSWORD_CHECK_PWN = false
515517

516518
[openid]
517519
;

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -344,6 +344,7 @@ set name for unique queues. Individual queues will default to
344344
- digit - use one or more digits
345345
- spec - use one or more special characters as ``!"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~``
346346
- off - do not check password complexity
347+
- `PASSWORD_CHECK_PWN`: **false**: Check [HaveIBeenPwned](https://haveibeenpwned.com/Passwords) to see if a password has been exposed.
347348

348349
## OpenID (`openid`)
349350

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ require (
9898
github.com/yuin/goldmark v1.2.1
9999
github.com/yuin/goldmark-highlighting v0.0.0-20200307114337-60d527fdb691
100100
github.com/yuin/goldmark-meta v0.0.0-20191126180153-f0638e958b60
101+
go.jolheiser.com/pwn v0.0.3
101102
golang.org/x/crypto v0.0.0-20200820211705-5c72a883971a
102103
golang.org/x/net v0.0.0-20200904194848-62affa334b73
103104
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d

go.sum

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -918,8 +918,9 @@ github.com/ziutek/mymysql v1.5.4/go.mod h1:LMSpPZ6DbqWFxNCHW77HeMg9I646SAhApZ/wK
918918
go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU=
919919
go.etcd.io/bbolt v1.3.5 h1:XAzx9gjCb0Rxj7EoqcClPD1d5ZBxZJk0jbuoPHenBt0=
920920
go.etcd.io/bbolt v1.3.5/go.mod h1:G5EMThwa9y8QZGBClrRx5EY+Yw9kAhnjy3bSjsnlVTQ=
921+
go.jolheiser.com/pwn v0.0.3 h1:MQowb3QvCL5r5NmHmCPxw93SdjfgJ0q6rAwYn4i1Hjg=
922+
go.jolheiser.com/pwn v0.0.3/go.mod h1:/j5Dl8ftNqqJ8Dlx3YTrJV1wIR2lWOTyrNU3Qe7rk6I=
921923
go.mongodb.org/mongo-driver v1.0.3/go.mod h1:u7ryQJ+DOzQmeO7zB6MHyr8jkEQvC8vH7qLUO4lqsUM=
922-
go.mongodb.org/mongo-driver v1.1.1 h1:Sq1fR+0c58RME5EoqKdjkiQAmPjmfHlZOoRI6fTUOcs=
923924
go.mongodb.org/mongo-driver v1.1.1/go.mod h1:u7ryQJ+DOzQmeO7zB6MHyr8jkEQvC8vH7qLUO4lqsUM=
924925
go.mongodb.org/mongo-driver v1.3.0/go.mod h1:MSWZXKOynuguX+JSvwP8i+58jYCXxbia8HS3gZBapIE=
925926
go.mongodb.org/mongo-driver v1.3.4/go.mod h1:MSWZXKOynuguX+JSvwP8i+58jYCXxbia8HS3gZBapIE=
@@ -977,7 +978,6 @@ golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCc
977978
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
978979
golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY=
979980
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
980-
golang.org/x/mod v0.2.0 h1:KU7oHjnv3XNWfa5COkzUifxZmxp1TyI7ImMXqFxLwvQ=
981981
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
982982
golang.org/x/mod v0.3.0 h1:RM4zey1++hCTbCVQfnWeKs9/IEsaBLA8vTkd0WVtmH4=
983983
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
@@ -1012,7 +1012,6 @@ golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLL
10121012
golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
10131013
golang.org/x/net v0.0.0-20200602114024-627f9648deb9/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
10141014
golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
1015-
golang.org/x/net v0.0.0-20200707034311-ab3426394381 h1:VXak5I6aEWmAXeQjA+QSZzlgNrpq9mjcfDemuexIKsU=
10161015
golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
10171016
golang.org/x/net v0.0.0-20200904194848-62affa334b73 h1:MXfv8rhZWmFeqX3GNZRsd6vOLoaCHjYEX3qkRo3YBUA=
10181017
golang.org/x/net v0.0.0-20200904194848-62affa334b73/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=

modules/password/password.go

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ package password
66

77
import (
88
"bytes"
9+
goContext "context"
910
"crypto/rand"
1011
"math/big"
1112
"strings"
@@ -88,7 +89,7 @@ func IsComplexEnough(pwd string) bool {
8889
return true
8990
}
9091

91-
// Generate a random password
92+
// Generate a random password
9293
func Generate(n int) (string, error) {
9394
NewComplexity()
9495
buffer := make([]byte, n)
@@ -101,7 +102,11 @@ func Generate(n int) (string, error) {
101102
}
102103
buffer[j] = validChars[rnd.Int64()]
103104
}
104-
if IsComplexEnough(string(buffer)) && string(buffer[0]) != " " && string(buffer[n-1]) != " " {
105+
pwned, err := IsPwned(goContext.Background(), string(buffer))
106+
if err != nil {
107+
return "", err
108+
}
109+
if IsComplexEnough(string(buffer)) && !pwned && string(buffer[0]) != " " && string(buffer[n-1]) != " " {
105110
return string(buffer), nil
106111
}
107112
}

modules/password/pwn.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
// Copyright 2020 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 password
6+
7+
import (
8+
"context"
9+
10+
"code.gitea.io/gitea/modules/setting"
11+
12+
"go.jolheiser.com/pwn"
13+
)
14+
15+
// IsPwned checks whether a password has been pwned
16+
// NOTE: This func returns true if it encounters an error under the assumption that you ALWAYS want to check against
17+
// HIBP, so not getting a response should block a password until it can be verified.
18+
func IsPwned(ctx context.Context, password string) (bool, error) {
19+
if !setting.PasswordCheckPwn {
20+
return false, nil
21+
}
22+
23+
client := pwn.New(pwn.WithContext(ctx))
24+
count, err := client.CheckPassword(password, true)
25+
if err != nil {
26+
return true, err
27+
}
28+
29+
return count > 0, nil
30+
}

modules/setting/setting.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,7 @@ var (
146146
OnlyAllowPushIfGiteaEnvironmentSet bool
147147
PasswordComplexity []string
148148
PasswordHashAlgo string
149+
PasswordCheckPwn bool
149150

150151
// UI settings
151152
UI = struct {
@@ -744,6 +745,7 @@ func NewContext() {
744745
OnlyAllowPushIfGiteaEnvironmentSet = sec.Key("ONLY_ALLOW_PUSH_IF_GITEA_ENVIRONMENT_SET").MustBool(true)
745746
PasswordHashAlgo = sec.Key("PASSWORD_HASH_ALGO").MustString("argon2")
746747
CSRFCookieHTTPOnly = sec.Key("CSRF_COOKIE_HTTP_ONLY").MustBool(true)
748+
PasswordCheckPwn = sec.Key("PASSWORD_CHECK_PWN").MustBool(false)
747749

748750
InternalToken = loadInternalToken(sec)
749751

options/locale/locale_en-US.ini

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,6 +300,8 @@ authorization_failed = Authorization failed
300300
authorization_failed_desc = The authorization failed because we detected an invalid request. Please contact the maintainer of the app you've tried to authorize.
301301
disable_forgot_password_mail = Account recovery is disabled. Please contact your site administrator.
302302
sspi_auth_failed = SSPI authentication failed
303+
password_pwned = The password you chose is on a <a target="_blank" rel="noopener noreferrer" href="https://haveibeenpwned.com/Passwords">list of stolen passwords</a> previously exposed in public data breaches. Please try again with a different password.
304+
password_pwned_err = Could not complete request to HaveIBeenPwned
303305
304306
[mail]
305307
activate_account = Please activate your account

routers/admin/users.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,17 @@ func NewUserPost(ctx *context.Context, form auth.AdminCreateUserForm) {
108108
ctx.RenderWithErr(password.BuildComplexityError(ctx), tplUserNew, &form)
109109
return
110110
}
111+
pwned, err := password.IsPwned(ctx.Req.Context(), form.Password)
112+
if pwned {
113+
ctx.Data["Err_Password"] = true
114+
errMsg := ctx.Tr("auth.password_pwned")
115+
if err != nil {
116+
log.Error(err.Error())
117+
errMsg = ctx.Tr("auth.password_pwned_err")
118+
}
119+
ctx.RenderWithErr(errMsg, tplUserNew, &form)
120+
return
121+
}
111122
u.MustChangePassword = form.MustChangePassword
112123
}
113124
if err := models.CreateUser(u); err != nil {
@@ -224,6 +235,17 @@ func EditUserPost(ctx *context.Context, form auth.AdminEditUserForm) {
224235
ctx.RenderWithErr(password.BuildComplexityError(ctx), tplUserEdit, &form)
225236
return
226237
}
238+
pwned, err := password.IsPwned(ctx.Req.Context(), form.Password)
239+
if pwned {
240+
ctx.Data["Err_Password"] = true
241+
errMsg := ctx.Tr("auth.password_pwned")
242+
if err != nil {
243+
log.Error(err.Error())
244+
errMsg = ctx.Tr("auth.password_pwned_err")
245+
}
246+
ctx.RenderWithErr(errMsg, tplUserNew, &form)
247+
return
248+
}
227249
if u.Salt, err = models.GetUserSalt(); err != nil {
228250
ctx.ServerError("UpdateUser", err)
229251
return

routers/api/v1/admin/user.go

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,15 @@ func CreateUser(ctx *context.APIContext, form api.CreateUserOption) {
8787
ctx.Error(http.StatusBadRequest, "PasswordComplexity", err)
8888
return
8989
}
90+
pwned, err := password.IsPwned(ctx.Req.Context(), form.Password)
91+
if pwned {
92+
if err != nil {
93+
log.Error(err.Error())
94+
}
95+
ctx.Data["Err_Password"] = true
96+
ctx.Error(http.StatusBadRequest, "PasswordPwned", errors.New("PasswordPwned"))
97+
return
98+
}
9099
if err := models.CreateUser(u); err != nil {
91100
if models.IsErrUserAlreadyExist(err) ||
92101
models.IsErrEmailAlreadyUsed(err) ||
@@ -151,7 +160,15 @@ func EditUser(ctx *context.APIContext, form api.EditUserOption) {
151160
ctx.Error(http.StatusBadRequest, "PasswordComplexity", err)
152161
return
153162
}
154-
var err error
163+
pwned, err := password.IsPwned(ctx.Req.Context(), form.Password)
164+
if pwned {
165+
if err != nil {
166+
log.Error(err.Error())
167+
}
168+
ctx.Data["Err_Password"] = true
169+
ctx.Error(http.StatusBadRequest, "PasswordPwned", errors.New("PasswordPwned"))
170+
return
171+
}
155172
if u.Salt, err = models.GetUserSalt(); err != nil {
156173
ctx.Error(http.StatusInternalServerError, "UpdateUser", err)
157174
return

routers/user/auth.go

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1110,6 +1110,17 @@ func SignUpPost(ctx *context.Context, cpt *captcha.Captcha, form auth.RegisterFo
11101110
ctx.RenderWithErr(password.BuildComplexityError(ctx), tplSignUp, &form)
11111111
return
11121112
}
1113+
pwned, err := password.IsPwned(ctx.Req.Context(), form.Password)
1114+
if pwned {
1115+
errMsg := ctx.Tr("auth.password_pwned")
1116+
if err != nil {
1117+
log.Error(err.Error())
1118+
errMsg = ctx.Tr("auth.password_pwned_err")
1119+
}
1120+
ctx.Data["Err_Password"] = true
1121+
ctx.RenderWithErr(errMsg, tplSignUp, &form)
1122+
return
1123+
}
11131124

11141125
u := &models.User{
11151126
Name: form.UserName,
@@ -1409,6 +1420,16 @@ func ResetPasswdPost(ctx *context.Context) {
14091420
ctx.Data["Err_Password"] = true
14101421
ctx.RenderWithErr(password.BuildComplexityError(ctx), tplResetPassword, nil)
14111422
return
1423+
} else if pwned, err := password.IsPwned(ctx.Req.Context(), passwd); pwned || err != nil {
1424+
errMsg := ctx.Tr("auth.password_pwned")
1425+
if err != nil {
1426+
log.Error(err.Error())
1427+
errMsg = ctx.Tr("auth.password_pwned_err")
1428+
}
1429+
ctx.Data["IsResetForm"] = true
1430+
ctx.Data["Err_Password"] = true
1431+
ctx.RenderWithErr(errMsg, tplResetPassword, nil)
1432+
return
14121433
}
14131434

14141435
// Handle two-factor
@@ -1443,7 +1464,6 @@ func ResetPasswdPost(ctx *context.Context) {
14431464
}
14441465
}
14451466
}
1446-
14471467
var err error
14481468
if u.Rands, err = models.GetUserSalt(); err != nil {
14491469
ctx.ServerError("UpdateUser", err)

routers/user/setting/account.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,13 @@ func AccountPost(ctx *context.Context, form auth.ChangePasswordForm) {
5454
ctx.Flash.Error(ctx.Tr("form.password_not_match"))
5555
} else if !password.IsComplexEnough(form.Password) {
5656
ctx.Flash.Error(password.BuildComplexityError(ctx))
57+
} else if pwned, err := password.IsPwned(ctx.Req.Context(), form.Password); pwned || err != nil {
58+
errMsg := ctx.Tr("auth.password_pwned")
59+
if err != nil {
60+
log.Error(err.Error())
61+
errMsg = ctx.Tr("auth.password_pwned_err")
62+
}
63+
ctx.Flash.Error(errMsg)
5764
} else {
5865
var err error
5966
if ctx.User.Salt, err = models.GetUserSalt(); err != nil {

vendor/go.jolheiser.com/pwn/.gitignore

Lines changed: 6 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

vendor/go.jolheiser.com/pwn/LICENSE

Lines changed: 7 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

vendor/go.jolheiser.com/pwn/Makefile

Lines changed: 18 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

vendor/go.jolheiser.com/pwn/README.md

Lines changed: 13 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

vendor/go.jolheiser.com/pwn/error.go

Lines changed: 15 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

vendor/go.jolheiser.com/pwn/go.mod

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)