Skip to content
Merged
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
2 changes: 1 addition & 1 deletion cmd/serv.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ import (
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/services/lfs"

"github.com/golang-jwt/jwt"
"github.com/golang-jwt/jwt/v4"
"github.com/kballard/go-shellquote"
"github.com/urfave/cli"
)
Expand Down
4 changes: 2 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ require (
github.com/gogs/chardet v0.0.0-20191104214054-4b6791f73a28
github.com/gogs/cron v0.0.0-20171120032916-9f6c956d3e14
github.com/gogs/go-gogs-client v0.0.0-20210131175652-1d7215cd8d85
github.com/golang-jwt/jwt v3.2.2+incompatible
github.com/golang-jwt/jwt/v4 v4.2.0
github.com/golang/snappy v0.0.4 // indirect
github.com/google/go-github/v39 v39.2.0
github.com/google/uuid v1.2.0
Expand Down Expand Up @@ -141,4 +141,4 @@ require (

replace github.com/hashicorp/go-version => github.com/6543/go-version v1.3.1

replace github.com/golang-jwt/jwt v3.2.1+incompatible => github.com/golang-jwt/jwt v3.2.2+incompatible
replace github.com/markbates/goth v1.68.0 => github.com/zeripath/goth v1.68.1-0.20220109111530-754359885dce
9 changes: 4 additions & 5 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -504,10 +504,9 @@ github.com/gogs/cron v0.0.0-20171120032916-9f6c956d3e14 h1:yXtpJr/LV6PFu4nTLgfjQ
github.com/gogs/cron v0.0.0-20171120032916-9f6c956d3e14/go.mod h1:jPoNZLWDAqA5N3G5amEoiNbhVrmM+ZQEcnQvNQ2KaZk=
github.com/gogs/go-gogs-client v0.0.0-20210131175652-1d7215cd8d85 h1:UjoPNDAQ5JPCjlxoJd6K8ALZqSDDhk2ymieAZOVaDg0=
github.com/gogs/go-gogs-client v0.0.0-20210131175652-1d7215cd8d85/go.mod h1:fR6z1Ie6rtF7kl/vBYMfgD5/G5B1blui7z426/sj2DU=
github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
github.com/golang-jwt/jwt/v4 v4.1.0 h1:XUgk2Ex5veyVFVeLm0xhusUTQybEbexJXrvPNOKkSY0=
github.com/golang-jwt/jwt/v4 v4.1.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzwAxVc6locg=
github.com/golang-jwt/jwt/v4 v4.2.0 h1:besgBTC8w8HjP6NzQdxwKH9Z5oQMZ24ThTrHp3cZ8eU=
github.com/golang-jwt/jwt/v4 v4.2.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzwAxVc6locg=
github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe h1:lXe2qZdvpiX5WZkZR4hgp4KJVfY3nMkvmwbVkpv1rVY=
github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
Expand Down Expand Up @@ -827,8 +826,6 @@ github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/markbates/going v1.0.0 h1:DQw0ZP7NbNlFGcKbcE/IVSOAFzScxRtLpd0rLMzLhq0=
github.com/markbates/going v1.0.0/go.mod h1:I6mnB4BPnEeqo85ynXIx1ZFLLbtiLHNXVgWeFO9OGOA=
github.com/markbates/goth v1.68.0 h1:90sKvjRAKHcl9V2uC9x/PJXeD78cFPiBsyP1xVhoQfA=
github.com/markbates/goth v1.68.0/go.mod h1:V2VcDMzDiMHW+YmqYl7i0cMiAUeCkAe4QE6jRKBhXZw=
github.com/markbates/oncer v0.0.0-20181203154359-bf2de49a0be2/go.mod h1:Ld9puTsIW75CHf65OeIOkyKbteujpZVXDpWK6YGZbxE=
github.com/markbates/safe v1.0.1/go.mod h1:nAqgmRi7cY2nqMc92/bSEeQA+R4OheNU2T1kNSCBdG0=
github.com/matryer/is v1.2.0 h1:92UTHpy8CDwaJ08GqLDzhhuixiBUUD1p3AU6PHddz4A=
Expand Down Expand Up @@ -1182,6 +1179,8 @@ github.com/yuin/goldmark-highlighting v0.0.0-20210516132338-9216f9c5aa01/go.mod
github.com/yuin/goldmark-meta v1.0.0 h1:ScsatUIT2gFS6azqzLGUjgOnELsBOxMXerM3ogdJhAM=
github.com/yuin/goldmark-meta v1.0.0/go.mod h1:zsNNOrZ4nLuyHAJeLQEZcQat8dm70SmB2kHbls092Gc=
github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q=
github.com/zeripath/goth v1.68.1-0.20220109111530-754359885dce h1:ul/k+Fu3/2h+hxIaEMrn6m96X1Wf+TQk9G7zyuvy1Ws=
github.com/zeripath/goth v1.68.1-0.20220109111530-754359885dce/go.mod h1:uk3KIdtCKdmyNABgOSmHFNHN0AcKqkLs8j5Ak3Ioe1Q=
github.com/ziutek/mymysql v1.5.4/go.mod h1:LMSpPZ6DbqWFxNCHW77HeMg9I646SAhApZ/wKdgO/C0=
go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU=
go.etcd.io/bbolt v1.3.3/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU=
Expand Down
37 changes: 37 additions & 0 deletions models/auth/webauthn.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,11 @@ import (
"context"
"encoding/base64"
"fmt"
"strings"

"code.gitea.io/gitea/models/db"
"code.gitea.io/gitea/modules/timeutil"
"xorm.io/xorm"

"github.com/duo-labs/webauthn/webauthn"
)
Expand Down Expand Up @@ -39,6 +41,7 @@ func IsErrWebAuthnCredentialNotExist(err error) bool {
type WebAuthnCredential struct {
ID int64 `xorm:"pk autoincr"`
Name string `xorm:"unique(s)"`
LowerName string
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So, the tag xorm:"unique(s)" should be in the LowerName field.

UserID int64 `xorm:"INDEX unique(s)"`
CredentialID string `xorm:"INDEX"`
PublicKey []byte
Expand Down Expand Up @@ -69,6 +72,16 @@ func (cred *WebAuthnCredential) updateSignCount(ctx context.Context) error {
return err
}

// BeforeUpdate will be invoked by XORM before updating a record
func (cred *WebAuthnCredential) BeforeUpdate() {
cred.LowerName = strings.ToLower(cred.Name)
}

// AfterLoad is invoked from XORM after setting the values of all fields of this object.
func (cred *WebAuthnCredential) AfterLoad(session *xorm.Session) {
cred.LowerName = strings.ToLower(cred.Name)
}

// WebAuthnCredentialList is a list of *WebAuthnCredential
type WebAuthnCredentialList []*WebAuthnCredential

Expand Down Expand Up @@ -101,6 +114,30 @@ func getWebAuthnCredentialsByUID(ctx context.Context, uid int64) (WebAuthnCreden
return creds, db.GetEngine(ctx).Where("user_id = ?", uid).Find(&creds)
}

//ExistsWebAuthnCredentialsForUID returns if the given user has credentials
func ExistsWebAuthnCredentialsForUID(uid int64) (bool, error) {
return existsWebAuthnCredentialsByUID(db.DefaultContext, uid)
}

func existsWebAuthnCredentialsByUID(ctx context.Context, uid int64) (bool, error) {
return db.GetEngine(ctx).Where("user_id = ?", uid).Exist(&WebAuthnCredential{})
}

// GetWebAuthnCredentialByName returns WebAuthn credential by id
func GetWebAuthnCredentialByName(uid int64, name string) (*WebAuthnCredential, error) {
return getWebAuthnCredentialByName(db.DefaultContext, uid, name)
}

func getWebAuthnCredentialByName(ctx context.Context, uid int64, name string) (*WebAuthnCredential, error) {
cred := new(WebAuthnCredential)
if found, err := db.GetEngine(ctx).Where("user_id = ? AND lower_name = ?", uid, strings.ToLower(name)).Get(cred); err != nil {
return nil, err
} else if !found {
return nil, ErrWebAuthnCredentialNotExist{}
}
return cred, nil
}

// GetWebAuthnCredentialByID returns WebAuthn credential by id
func GetWebAuthnCredentialByID(id int64) (*WebAuthnCredential, error) {
return getWebAuthnCredentialByID(db.DefaultContext, id)
Expand Down
29 changes: 9 additions & 20 deletions models/migrations/v207.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ package migrations
import (
"crypto/elliptic"
"encoding/base64"
"strings"

"code.gitea.io/gitea/modules/timeutil"

Expand All @@ -15,10 +16,13 @@ import (
)

func addWebAuthnCred(x *xorm.Engine) error {

// Create webauthnCredential table
type webauthnCredential struct {
ID int64 `xorm:"pk autoincr"`
Name string
UserID int64 `xorm:"INDEX"`
ID int64 `xorm:"pk autoincr"`
Name string `xorm:"unique(s)"`
LowerName string
UserID int64 `xorm:"INDEX unique(s)"`
CredentialID string `xorm:"INDEX"`
PublicKey []byte
AttestationType string
Expand All @@ -31,10 +35,8 @@ func addWebAuthnCred(x *xorm.Engine) error {
if err := x.Sync2(&webauthnCredential{}); err != nil {
return err
}
return migrateU2FToWebAuthn(x)
}

func migrateU2FToWebAuthn(x *xorm.Engine) error {
// Now migrate the old u2f registrations to the new format
type u2fRegistration struct {
ID int64 `xorm:"pk autoincr"`
Name string
Expand All @@ -45,20 +47,6 @@ func migrateU2FToWebAuthn(x *xorm.Engine) error {
UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"`
}

type webauthnCredential struct {
ID int64 `xorm:"pk autoincr"`
Name string
UserID int64 `xorm:"INDEX"`
CredentialID string `xorm:"INDEX"`
PublicKey []byte
AttestationType string
AAGUID []byte
SignCount uint32 `xorm:"BIGINT"`
CloneWarning bool
CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"`
UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"`
}

var start int
regs := make([]*u2fRegistration, 0, 50)
for {
Expand All @@ -77,6 +65,7 @@ func migrateU2FToWebAuthn(x *xorm.Engine) error {
c := &webauthnCredential{
ID: reg.ID,
Name: reg.Name,
LowerName: strings.ToLower(reg.Name),
UserID: reg.UserID,
CredentialID: base64.RawURLEncoding.EncodeToString(parsed.KeyHandle),
PublicKey: elliptic.Marshal(elliptic.P256(), parsed.PubKey.X, parsed.PubKey.Y),
Expand Down
2 changes: 1 addition & 1 deletion modules/generate/generate.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import (

"code.gitea.io/gitea/modules/util"

"github.com/golang-jwt/jwt"
"github.com/golang-jwt/jwt/v4"
)

// NewInternalToken generate a new value intended to be used by INTERNAL_TOKEN.
Expand Down
10 changes: 5 additions & 5 deletions options/locale/locale_en-US.ini
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,11 @@ webauthn_press_button = Please press the button on your security key…
webauthn_use_twofa = Use a two-factor code from your phone
webauthn_error = Could not read your security key.
webauthn_unsupported_browser = Your browser does not currently support WebAuthn.
webauthn_error_1 = An unknown error occurred. Please retry.
webauthn_error_2 = WebAuthn only supports secure connections. For testing over HTTP, you can use the origin "localhost" or "127.0.0.1"
webauthn_error_3 = The server could not process your request.
webauthn_error_4 = The security key is not permitted for this request. Please make sure that the key is not already registered.
webauthn_error_5 = Timeout reached before your key could be read. Please reload this page and retry.
webauthn_error_unknown = An unknown error occurred. Please retry.
webauthn_error_insecure = WebAuthn only supports secure connections. For testing over HTTP, you can use the origin "localhost" or "127.0.0.1"
webauthn_error_unable_to_process = The server could not process your request.
webauthn_error_duplicated = The security key is not permitted for this request. Please make sure that the key is not already registered.
webauthn_error_timeout = Timeout reached before your key could be read. Please reload this page and retry.
webauthn_reload = Reload

repository = Repository
Expand Down
2 changes: 1 addition & 1 deletion routers/web/auth/oauth.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ import (
user_service "code.gitea.io/gitea/services/user"

"gitea.com/go-chi/binding"
"github.com/golang-jwt/jwt"
"github.com/golang-jwt/jwt/v4"
"github.com/markbates/goth"
)

Expand Down
2 changes: 1 addition & 1 deletion routers/web/auth/oauth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import (
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/services/auth/source/oauth2"

"github.com/golang-jwt/jwt"
"github.com/golang-jwt/jwt/v4"
"github.com/stretchr/testify/assert"
)

Expand Down
50 changes: 26 additions & 24 deletions routers/web/auth/webauthn.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,24 +45,24 @@ func WebAuthn(ctx *context.Context) {
// WebAuthnLoginAssertion submits a WebAuthn challenge to the browser
func WebAuthnLoginAssertion(ctx *context.Context) {
// Ensure user is in a WebAuthn session.
idSess := ctx.Session.Get("twofaUid")
if idSess == nil {
idSess, ok := ctx.Session.Get("twofaUid").(int64)
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It will panic if there is no session named twofaUid.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The use of the x, ok := something.(otherthing) form prevents the NPE if something is a nil interface{}

if !ok || idSess == 0 {
ctx.ServerError("UserSignIn", errors.New("not in WebAuthn session"))
return
}

user, err := user_model.GetUserByID(idSess.(int64))
user, err := user_model.GetUserByID(idSess)
if err != nil {
ctx.ServerError("UserSignIn", err)
return
}

creds, err := auth.WebAuthnCredentials(user.ID)
exists, err := auth.ExistsWebAuthnCredentialsForUID(user.ID)
if err != nil {
ctx.ServerError("UserSignIn", err)
return
}
if len(creds) == 0 {
if !exists {
ctx.ServerError("UserSignIn", errors.New("no device registered"))
return
}
Expand Down Expand Up @@ -90,45 +90,46 @@ func WebAuthnLoginAssertion(ctx *context.Context) {

// WebAuthnLoginAssertionPost validates the signature and logs the user in
func WebAuthnLoginAssertionPost(ctx *context.Context) {
idSess := ctx.Session.Get("twofaUid")
sessionData := ctx.Session.Get("webauthnAssertion")
if sessionData == nil || idSess == nil {
idSess, ok := ctx.Session.Get("twofaUid").(int64)
sessionData, okData := ctx.Session.Get("webauthnAssertion").(*webauthn.SessionData)
if !ok || !okData || sessionData == nil || idSess == 0 {
ctx.ServerError("UserSignIn", errors.New("not in WebAuthn session"))
return
}
defer func() {
_ = ctx.Session.Delete("webauthnAssertion")
}()
user, err := user_model.GetUserByID(idSess.(int64))

// Load the user from the db
user, err := user_model.GetUserByID(idSess)
if err != nil {
ctx.ServerError("UserSignIn", err)
return
}

log.Trace("Finishing webauthn authentication with user: %s\n", user.Name)
log.Trace("Finishing webauthn authentication with user: %s", user.Name)

// With the session data retrieved, we need to call webauthn.FinishLogin to
// verify the signed challenge. This returns the webauthn.Credential that
// was used to authenticate.
cred, err := wa.WebAuthn.FinishLogin((*wa.User)(user), *sessionData.(*webauthn.SessionData), ctx.Req)
// Now we call webauthn.FinishLogin using a combination of our session data
// (from webauthnAssertion) and verify the provided request.
//
// FinishLogin will then return the webauthn.Credential that was used to authenticate.
cred, err := wa.WebAuthn.FinishLogin((*wa.User)(user), *sessionData, ctx.Req)
if err != nil {
ctx.ServerError("FinishLogin", err)
// Failed authentication attempt.
log.Info("Failed authentication attempt for %s from %s: %v", user.Name, ctx.RemoteAddr(), err)
ctx.Status(http.StatusForbidden)
return
}

// At this point, we've confirmed the correct authenticator has been
// provided and it passed the challenge we gave it. We now need to make
// sure that the sign counter is higher than what we have stored to help
// give assurance that this credential wasn't cloned.
// Ensure that the credential wasn't cloned by checking if CloneWarning is set.
// (This is set if the sign counter is less than the one we have stored.)
if cred.Authenticator.CloneWarning {
log.Error("credential appears to be cloned: %s", err)
log.Info("Failed authentication attempt for %s from %s: cloned credential", user.Name, ctx.RemoteAddr())
ctx.Status(http.StatusForbidden)
return
}
// We're logged in! All that's left is to update the sign count with the
// new value we received. We could join the tables on the CredentialID
// field, but for our purposes we'll just get the stored credential and
// use that to find the authenticator we need to update.

// Success! Get the credential and update the sign count with the new value we received.
dbCred, err := auth.GetWebAuthnCredentialByCredID(base64.RawStdEncoding.EncodeToString(cred.ID))
if err != nil {
ctx.ServerError("GetWebAuthnCredentialByCredID", err)
Expand All @@ -141,6 +142,7 @@ func WebAuthnLoginAssertionPost(ctx *context.Context) {
return
}

// Now handle account linking if that's requested
if ctx.Session.Get("linkAccount") != nil {
if err := externalaccount.LinkAccountFromStore(ctx.Session, user); err != nil {
ctx.ServerError("LinkAccountFromStore", err)
Expand Down
20 changes: 7 additions & 13 deletions routers/web/user/setting/security/webauthn.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,17 +27,16 @@ func WebAuthnRegister(ctx *context.Context) {
ctx.Error(http.StatusConflict)
return
}
creds, err := auth.GetWebAuthnCredentialsByUID(ctx.User.ID)
if err != nil {
cred, err := auth.GetWebAuthnCredentialByName(ctx.User.ID, form.Name)
if err != nil && !auth.IsErrWebAuthnCredentialNotExist(err) {
ctx.ServerError("GetWebAuthnCredentialsByUID", err)
return
}
for _, reg := range creds {
if strings.EqualFold(reg.Name, form.Name) {
ctx.Error(http.StatusConflict, "Name already taken")
return
}
if cred != nil {
ctx.Error(http.StatusConflict, "Name already taken")
return
}

_ = ctx.Session.Delete("registration")
if err := ctx.Session.Set("WebauthnName", form.Name); err != nil {
ctx.ServerError("Unable to set session key for WebauthnName", err)
Expand Down Expand Up @@ -101,13 +100,8 @@ func WebauthnRegisterPost(ctx *context.Context) {
ctx.ServerError("CreateCredential", err)
return
}
// If needed, you can perform additional checks here to ensure the
// authenticator and generated credential conform to your requirements.

// For our use case, we're encoding the raw credential ID as URL-safe
// base64 since we anticipate rendering it in templates. If you choose to
// do this, make sure to decode the credential ID before passing it back to
// the webauthn library.
// Create the credential
_, err = auth.CreateCredential(ctx.User.ID, name.(string), cred)
if err != nil {
ctx.ServerError("CreateCredential", err)
Expand Down
2 changes: 1 addition & 1 deletion services/auth/source/oauth2/jwtsigningkey.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import (
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/util"

"github.com/golang-jwt/jwt"
"github.com/golang-jwt/jwt/v4"
ini "gopkg.in/ini.v1"
)

Expand Down
2 changes: 1 addition & 1 deletion services/auth/source/oauth2/token.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import (

"code.gitea.io/gitea/modules/timeutil"

"github.com/golang-jwt/jwt"
"github.com/golang-jwt/jwt/v4"
)

// ___________ __
Expand Down
Loading