From 71d16f69ff9448e55f371ce8354d978f8dbe2cba Mon Sep 17 00:00:00 2001 From: Sandro Santilli Date: Fri, 17 Mar 2017 15:16:08 +0100 Subject: [PATCH] Login via OpenID-2.0 (#618) --- cmd/web.go | 21 + conf/app.ini | 32 ++ models/error.go | 15 + models/migrations/migrations.go | 2 + models/migrations/v23.go | 26 ++ models/models.go | 1 + models/user.go | 1 + models/user_openid.go | 117 +++++ modules/auth/openid/discovery_cache.go | 59 +++ modules/auth/openid/discovery_cache_test.go | 47 ++ modules/auth/openid/openid.go | 37 ++ modules/auth/user_form.go | 12 +- modules/auth/user_form_auth_openid.go | 45 ++ modules/context/context.go | 1 + modules/setting/setting.go | 25 + options/locale/locale_en-US.ini | 17 + public/img/openid-16x16.png | Bin 0 -> 230 bytes routers/user/auth.go | 10 +- routers/user/auth_openid.go | 426 ++++++++++++++++++ routers/user/setting_openid.go | 142 ++++++ templates/user/auth/finalize_openid.tmpl | 46 ++ templates/user/auth/signin.tmpl | 7 +- templates/user/auth/signin_inner.tmpl | 100 ++-- templates/user/auth/signin_navbar.tmpl | 11 + templates/user/auth/signin_openid.tmpl | 37 ++ .../user/auth/signup_openid_connect.tmpl | 45 ++ templates/user/auth/signup_openid_navbar.tmpl | 11 + .../user/auth/signup_openid_register.tmpl | 34 ++ templates/user/settings/navbar.tmpl | 7 +- templates/user/settings/openid.tmpl | 57 +++ vendor/github.com/yohcop/openid-go/LICENSE | 13 + vendor/github.com/yohcop/openid-go/README.md | 38 ++ .../github.com/yohcop/openid-go/discover.go | 57 +++ .../yohcop/openid-go/discovery_cache.go | 69 +++ vendor/github.com/yohcop/openid-go/getter.go | 31 ++ .../yohcop/openid-go/html_discovery.go | 77 ++++ .../yohcop/openid-go/nonce_store.go | 87 ++++ .../github.com/yohcop/openid-go/normalizer.go | 64 +++ vendor/github.com/yohcop/openid-go/openid.go | 15 + .../github.com/yohcop/openid-go/redirect.go | 55 +++ vendor/github.com/yohcop/openid-go/verify.go | 250 ++++++++++ vendor/github.com/yohcop/openid-go/xrds.go | 83 ++++ .../yohcop/openid-go/yadis_discovery.go | 119 +++++ vendor/vendor.json | 6 + 44 files changed, 2298 insertions(+), 57 deletions(-) create mode 100644 models/migrations/v23.go create mode 100644 models/user_openid.go create mode 100644 modules/auth/openid/discovery_cache.go create mode 100644 modules/auth/openid/discovery_cache_test.go create mode 100644 modules/auth/openid/openid.go create mode 100644 modules/auth/user_form_auth_openid.go create mode 100644 public/img/openid-16x16.png create mode 100644 routers/user/auth_openid.go create mode 100644 routers/user/setting_openid.go create mode 100644 templates/user/auth/finalize_openid.tmpl create mode 100644 templates/user/auth/signin_navbar.tmpl create mode 100644 templates/user/auth/signin_openid.tmpl create mode 100644 templates/user/auth/signup_openid_connect.tmpl create mode 100644 templates/user/auth/signup_openid_navbar.tmpl create mode 100644 templates/user/auth/signup_openid_register.tmpl create mode 100644 templates/user/settings/openid.tmpl create mode 100644 vendor/github.com/yohcop/openid-go/LICENSE create mode 100644 vendor/github.com/yohcop/openid-go/README.md create mode 100644 vendor/github.com/yohcop/openid-go/discover.go create mode 100644 vendor/github.com/yohcop/openid-go/discovery_cache.go create mode 100644 vendor/github.com/yohcop/openid-go/getter.go create mode 100644 vendor/github.com/yohcop/openid-go/html_discovery.go create mode 100644 vendor/github.com/yohcop/openid-go/nonce_store.go create mode 100644 vendor/github.com/yohcop/openid-go/normalizer.go create mode 100644 vendor/github.com/yohcop/openid-go/openid.go create mode 100644 vendor/github.com/yohcop/openid-go/redirect.go create mode 100644 vendor/github.com/yohcop/openid-go/verify.go create mode 100644 vendor/github.com/yohcop/openid-go/xrds.go create mode 100644 vendor/github.com/yohcop/openid-go/yadis_discovery.go diff --git a/cmd/web.go b/cmd/web.go index 0410ad5190..17674b3069 100644 --- a/cmd/web.go +++ b/cmd/web.go @@ -200,6 +200,19 @@ func runWeb(ctx *cli.Context) error { m.Group("/user", func() { m.Get("/login", user.SignIn) m.Post("/login", bindIgnErr(auth.SignInForm{}), user.SignInPost) + if setting.EnableOpenIDSignIn { + m.Combo("/login/openid"). + Get(user.SignInOpenID). + Post(bindIgnErr(auth.SignInOpenIDForm{}), user.SignInOpenIDPost) + m.Group("/openid", func() { + m.Combo("/connect"). + Get(user.ConnectOpenID). + Post(bindIgnErr(auth.ConnectOpenIDForm{}), user.ConnectOpenIDPost) + m.Combo("/register"). + Get(user.RegisterOpenID). + Post(bindIgnErr(auth.SignUpOpenIDForm{}), user.RegisterOpenIDPost) + }) + } m.Get("/sign_up", user.SignUp) m.Post("/sign_up", bindIgnErr(auth.RegisterForm{}), user.SignUpPost) m.Get("/reset_password", user.ResetPasswd) @@ -230,6 +243,14 @@ func runWeb(ctx *cli.Context) error { m.Post("/email/delete", user.DeleteEmail) m.Get("/password", user.SettingsPassword) m.Post("/password", bindIgnErr(auth.ChangePasswordForm{}), user.SettingsPasswordPost) + if setting.EnableOpenIDSignIn { + m.Group("/openid", func() { + m.Combo("").Get(user.SettingsOpenID). + Post(bindIgnErr(auth.AddOpenIDForm{}), user.SettingsOpenIDPost) + m.Post("/delete", user.DeleteOpenID) + }) + } + m.Combo("/ssh").Get(user.SettingsSSHKeys). Post(bindIgnErr(auth.AddSSHKeyForm{}), user.SettingsSSHKeysPost) m.Post("/ssh/delete", user.DeleteSSHKey) diff --git a/conf/app.ini b/conf/app.ini index 8e29e39b11..c2d41b8535 100644 --- a/conf/app.ini +++ b/conf/app.ini @@ -182,6 +182,38 @@ MIN_PASSWORD_LENGTH = 6 ; True when users are allowed to import local server paths IMPORT_LOCAL_PATHS = false +[openid] +; +; OpenID is an open standard and decentralized authentication protocol. +; Your identity is the address of a webpage you provide, which describes +; how to prove you are in control of that page. +; +; For more info: https://en.wikipedia.org/wiki/OpenID +; +; Current implementation supports OpenID-2.0 +; +; Tested to work providers at the time of writing: +; - Any GNUSocial node (your.hostname.tld/username) +; - Any SimpleID provider (http://simpleid.koinic.net) +; - http://openid.org.cn/ +; - openid.stackexchange.com +; - login.launchpad.net +; +; Whether to allow signin in via OpenID +ENABLE_OPENID_SIGNIN = true +; Whether to allow registering via OpenID +ENABLE_OPENID_SIGNUP = true +; Allowed URI patterns (POSIX regexp). +; Space separated. +; Only these would be allowed if non-blank. +; Example value: trusted.domain.org trusted.domain.net +WHITELISTED_URIS = +; Forbidden URI patterns (POSIX regexp). +; Space sepaated. +; Only used if WHITELISTED_URIS is blank. +; Example value: loadaverage.org/badguy stackexchange.com/.*spammer +BLACKLISTED_URIS = + [service] ACTIVE_CODE_LIVE_MINUTES = 180 RESET_PASSWD_CODE_LIVE_MINUTES = 180 diff --git a/models/error.go b/models/error.go index 62529f83fa..68bc238907 100644 --- a/models/error.go +++ b/models/error.go @@ -93,6 +93,21 @@ func (err ErrEmailAlreadyUsed) Error() string { return fmt.Sprintf("e-mail has been used [email: %s]", err.Email) } +// ErrOpenIDAlreadyUsed represents a "OpenIDAlreadyUsed" kind of error. +type ErrOpenIDAlreadyUsed struct { + OpenID string +} + +// IsErrOpenIDAlreadyUsed checks if an error is a ErrOpenIDAlreadyUsed. +func IsErrOpenIDAlreadyUsed(err error) bool { + _, ok := err.(ErrOpenIDAlreadyUsed) + return ok +} + +func (err ErrOpenIDAlreadyUsed) Error() string { + return fmt.Sprintf("OpenID has been used [oid: %s]", err.OpenID) +} + // ErrUserOwnRepos represents a "UserOwnRepos" kind of error. type ErrUserOwnRepos struct { UID int64 diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go index bf188dc4ce..4f1254b960 100644 --- a/models/migrations/migrations.go +++ b/models/migrations/migrations.go @@ -94,6 +94,8 @@ var migrations = []Migration{ NewMigration("rewrite authorized_keys file via new format", useNewPublickeyFormat), // v22 -> v23 NewMigration("generate and migrate wiki Git hooks", generateAndMigrateWikiGitHooks), + // v23 -> v24 + NewMigration("add user openid table", addUserOpenID), } // Migrate database to current version diff --git a/models/migrations/v23.go b/models/migrations/v23.go new file mode 100644 index 0000000000..efde684104 --- /dev/null +++ b/models/migrations/v23.go @@ -0,0 +1,26 @@ +// Copyright 2017 Gitea. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package migrations + +import ( + "fmt" + + "github.com/go-xorm/xorm" +) + +// UserOpenID is the list of all OpenID identities of a user. +type UserOpenID struct { + ID int64 `xorm:"pk autoincr"` + UID int64 `xorm:"INDEX NOT NULL"` + URI string `xorm:"UNIQUE NOT NULL"` +} + + +func addUserOpenID(x *xorm.Engine) error { + if err := x.Sync2(new(UserOpenID)); err != nil { + return fmt.Errorf("Sync2: %v", err) + } + return nil +} diff --git a/models/models.go b/models/models.go index bba4446db0..2ae6e355fc 100644 --- a/models/models.go +++ b/models/models.go @@ -116,6 +116,7 @@ func init() { new(RepoRedirect), new(ExternalLoginUser), new(ProtectedBranch), + new(UserOpenID), ) gonicNames := []string{"SSL", "UID"} diff --git a/models/user.go b/models/user.go index ff898573a6..ad303d7535 100644 --- a/models/user.go +++ b/models/user.go @@ -964,6 +964,7 @@ func deleteUser(e *xorm.Session, u *User) error { &Action{UserID: u.ID}, &IssueUser{UID: u.ID}, &EmailAddress{UID: u.ID}, + &UserOpenID{UID: u.ID}, ); err != nil { return fmt.Errorf("deleteBeans: %v", err) } diff --git a/models/user_openid.go b/models/user_openid.go new file mode 100644 index 0000000000..a5c88e9009 --- /dev/null +++ b/models/user_openid.go @@ -0,0 +1,117 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package models + +import ( + "errors" + + "code.gitea.io/gitea/modules/auth/openid" + "code.gitea.io/gitea/modules/log" +) + +var ( + // ErrOpenIDNotExist openid is not known + ErrOpenIDNotExist = errors.New("OpenID is unknown") +) + +// UserOpenID is the list of all OpenID identities of a user. +type UserOpenID struct { + ID int64 `xorm:"pk autoincr"` + UID int64 `xorm:"INDEX NOT NULL"` + URI string `xorm:"UNIQUE NOT NULL"` +} + +// GetUserOpenIDs returns all openid addresses that belongs to given user. +func GetUserOpenIDs(uid int64) ([]*UserOpenID, error) { + openids := make([]*UserOpenID, 0, 5) + if err := x. + Where("uid=?", uid). + Find(&openids); err != nil { + return nil, err + } + + return openids, nil +} + +func isOpenIDUsed(e Engine, uri string) (bool, error) { + if len(uri) == 0 { + return true, nil + } + + return e.Get(&UserOpenID{URI: uri}) +} + +// IsOpenIDUsed returns true if the openid has been used. +func IsOpenIDUsed(openid string) (bool, error) { + return isOpenIDUsed(x, openid) +} + +// NOTE: make sure openid.URI is normalized already +func addUserOpenID(e Engine, openid *UserOpenID) error { + used, err := isOpenIDUsed(e, openid.URI) + if err != nil { + return err + } else if used { + return ErrOpenIDAlreadyUsed{openid.URI} + } + + _, err = e.Insert(openid) + return err +} + +// AddUserOpenID adds an pre-verified/normalized OpenID URI to given user. +func AddUserOpenID(openid *UserOpenID) error { + return addUserOpenID(x, openid) +} + +// DeleteUserOpenID deletes an openid address of given user. +func DeleteUserOpenID(openid *UserOpenID) (err error) { + var deleted int64 + // ask to check UID + var address = UserOpenID{ + UID: openid.UID, + } + if openid.ID > 0 { + deleted, err = x.Id(openid.ID).Delete(&address) + } else { + deleted, err = x. + Where("openid=?", openid.URI). + Delete(&address) + } + + if err != nil { + return err + } else if deleted != 1 { + return ErrOpenIDNotExist + } + return nil +} + +// GetUserByOpenID returns the user object by given OpenID if exists. +func GetUserByOpenID(uri string) (*User, error) { + if len(uri) == 0 { + return nil, ErrUserNotExist{0, uri, 0} + } + + uri, err := openid.Normalize(uri) + if err != nil { + return nil, err + } + + log.Trace("Normalized OpenID URI: " + uri) + + // Otherwise, check in openid table + oid := &UserOpenID{URI: uri} + has, err := x.Get(oid) + if err != nil { + return nil, err + } + if has { + return GetUserByID(oid.UID) + } + + return nil, ErrUserNotExist{0, uri, 0} +} + diff --git a/modules/auth/openid/discovery_cache.go b/modules/auth/openid/discovery_cache.go new file mode 100644 index 0000000000..cf9f5ae70c --- /dev/null +++ b/modules/auth/openid/discovery_cache.go @@ -0,0 +1,59 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package openid + +import ( + "sync" + "time" + + "github.com/yohcop/openid-go" +) + +type timedDiscoveredInfo struct { + info openid.DiscoveredInfo + time time.Time +} + +type timedDiscoveryCache struct { + cache map[string]timedDiscoveredInfo + ttl time.Duration + mutex *sync.Mutex +} + +func newTimedDiscoveryCache(ttl time.Duration) *timedDiscoveryCache { + return &timedDiscoveryCache{cache: map[string]timedDiscoveredInfo{}, ttl: ttl, mutex: &sync.Mutex{}} +} + +func (s *timedDiscoveryCache) Put(id string, info openid.DiscoveredInfo) { + s.mutex.Lock() + defer s.mutex.Unlock() + + s.cache[id] = timedDiscoveredInfo{info: info, time: time.Now()} +} + +// Delete timed-out cache entries +func (s *timedDiscoveryCache) cleanTimedOut() { + now := time.Now() + for k, e := range s.cache { + diff := now.Sub(e.time) + if diff > s.ttl { + delete(s.cache, k) + } + } +} + +func (s *timedDiscoveryCache) Get(id string) openid.DiscoveredInfo { + s.mutex.Lock() + defer s.mutex.Unlock() + + // Delete old cached while we are at it. + s.cleanTimedOut() + + if info, has := s.cache[id]; has { + return info.info + } + return nil +} + diff --git a/modules/auth/openid/discovery_cache_test.go b/modules/auth/openid/discovery_cache_test.go new file mode 100644 index 0000000000..9de65a57bb --- /dev/null +++ b/modules/auth/openid/discovery_cache_test.go @@ -0,0 +1,47 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package openid + +import ( + "testing" + "time" +) + +type testDiscoveredInfo struct {} +func (s *testDiscoveredInfo) ClaimedID() string { + return "claimedID" +} +func (s *testDiscoveredInfo) OpEndpoint() string { + return "opEndpoint" +} +func (s *testDiscoveredInfo) OpLocalID() string { + return "opLocalID" +} + +func TestTimedDiscoveryCache(t *testing.T) { + dc := newTimedDiscoveryCache(1*time.Second) + + // Put some initial values + dc.Put("foo", &testDiscoveredInfo{}) //openid.opEndpoint: "a", openid.opLocalID: "b", openid.claimedID: "c"}) + + // Make sure we can retrieve them + if di := dc.Get("foo"); di == nil { + t.Errorf("Expected a result, got nil") + } else if di.OpEndpoint() != "opEndpoint" || di.OpLocalID() != "opLocalID" || di.ClaimedID() != "claimedID" { + t.Errorf("Expected opEndpoint opLocalID claimedID, got %v %v %v", di.OpEndpoint(), di.OpLocalID(), di.ClaimedID()) + } + + // Attempt to get a non-existent value + if di := dc.Get("bar"); di != nil { + t.Errorf("Expected nil, got %v", di) + } + + // Sleep one second and try retrive again + time.Sleep(1 * time.Second) + + if di := dc.Get("foo"); di != nil { + t.Errorf("Expected a nil, got a result") + } +} diff --git a/modules/auth/openid/openid.go b/modules/auth/openid/openid.go new file mode 100644 index 0000000000..aebdf15155 --- /dev/null +++ b/modules/auth/openid/openid.go @@ -0,0 +1,37 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package openid + +import ( + "github.com/yohcop/openid-go" + "time" +) + +// For the demo, we use in-memory infinite storage nonce and discovery +// cache. In your app, do not use this as it will eat up memory and +// never +// free it. Use your own implementation, on a better database system. +// If you have multiple servers for example, you may need to share at +// least +// the nonceStore between them. +var nonceStore = openid.NewSimpleNonceStore() +var discoveryCache = newTimedDiscoveryCache(24*time.Hour) + + +// Verify handles response from OpenID provider +func Verify(fullURL string) (id string, err error) { + return openid.Verify(fullURL, discoveryCache, nonceStore) +} + +// Normalize normalizes an OpenID URI +func Normalize(url string) (id string, err error) { + return openid.Normalize(url) +} + +// RedirectURL redirects browser +func RedirectURL(id, callbackURL, realm string) (string, error) { + return openid.RedirectURL(id, callbackURL, realm) +} + diff --git a/modules/auth/user_form.go b/modules/auth/user_form.go index 32987e6d37..9c6e38c460 100644 --- a/modules/auth/user_form.go +++ b/modules/auth/user_form.go @@ -78,7 +78,7 @@ func (f *RegisterForm) Validate(ctx *macaron.Context, errs binding.Errors) bindi return validate(errs, ctx.Data, f, ctx.Locale) } -// SignInForm form for signing in +// SignInForm form for signing in with user/password type SignInForm struct { UserName string `binding:"Required;MaxSize(254)"` Password string `binding:"Required;MaxSize(255)"` @@ -153,6 +153,16 @@ func (f *ChangePasswordForm) Validate(ctx *macaron.Context, errs binding.Errors) return validate(errs, ctx.Data, f, ctx.Locale) } +// AddOpenIDForm is for changing openid uri +type AddOpenIDForm struct { + Openid string `binding:"Required;MaxSize(256)"` +} + +// Validate validates the fields +func (f *AddOpenIDForm) Validate(ctx *macaron.Context, errs binding.Errors) binding.Errors { + return validate(errs, ctx.Data, f, ctx.Locale) +} + // AddSSHKeyForm form for adding SSH key type AddSSHKeyForm struct { Title string `binding:"Required;MaxSize(50)"` diff --git a/modules/auth/user_form_auth_openid.go b/modules/auth/user_form_auth_openid.go new file mode 100644 index 0000000000..582c6dc69f --- /dev/null +++ b/modules/auth/user_form_auth_openid.go @@ -0,0 +1,45 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package auth + +import ( + "github.com/go-macaron/binding" + "gopkg.in/macaron.v1" +) + + +// SignInOpenIDForm form for signing in with OpenID +type SignInOpenIDForm struct { + Openid string `binding:"Required;MaxSize(256)"` + Remember bool +} + +// Validate valideates the fields +func (f *SignInOpenIDForm) Validate(ctx *macaron.Context, errs binding.Errors) binding.Errors { + return validate(errs, ctx.Data, f, ctx.Locale) +} + +// SignUpOpenIDForm form for signin up with OpenID +type SignUpOpenIDForm struct { + UserName string `binding:"Required;AlphaDashDot;MaxSize(35)"` + Email string `binding:"Required;Email;MaxSize(254)"` +} + +// Validate valideates the fields +func (f *SignUpOpenIDForm) Validate(ctx *macaron.Context, errs binding.Errors) binding.Errors { + return validate(errs, ctx.Data, f, ctx.Locale) +} + +// ConnectOpenIDForm form for connecting an existing account to an OpenID URI +type ConnectOpenIDForm struct { + UserName string `binding:"Required;MaxSize(254)"` + Password string `binding:"Required;MaxSize(255)"` +} + +// Validate valideates the fields +func (f *ConnectOpenIDForm) Validate(ctx *macaron.Context, errs binding.Errors) binding.Errors { + return validate(errs, ctx.Data, f, ctx.Locale) +} + diff --git a/modules/context/context.go b/modules/context/context.go index fa53b484ee..52e50af6a1 100644 --- a/modules/context/context.go +++ b/modules/context/context.go @@ -197,6 +197,7 @@ func Contexter() macaron.Handler { ctx.Data["ShowRegistrationButton"] = setting.Service.ShowRegistrationButton ctx.Data["ShowFooterBranding"] = setting.ShowFooterBranding ctx.Data["ShowFooterVersion"] = setting.ShowFooterVersion + ctx.Data["EnableOpenIDSignIn"] = setting.EnableOpenIDSignIn c.Map(ctx) } diff --git a/modules/setting/setting.go b/modules/setting/setting.go index 520dc429df..0ac63d691f 100644 --- a/modules/setting/setting.go +++ b/modules/setting/setting.go @@ -15,6 +15,7 @@ import ( "os/exec" "path" "path/filepath" + "regexp" "runtime" "strconv" "strings" @@ -120,6 +121,12 @@ var ( MinPasswordLength int ImportLocalPaths bool + // OpenID settings + EnableOpenIDSignIn bool + EnableOpenIDSignUp bool + OpenIDWhitelist []*regexp.Regexp + OpenIDBlacklist []*regexp.Regexp + // Database settings UseSQLite3 bool UseMySQL bool @@ -755,6 +762,24 @@ please consider changing to GITEA_CUSTOM`) MinPasswordLength = sec.Key("MIN_PASSWORD_LENGTH").MustInt(6) ImportLocalPaths = sec.Key("IMPORT_LOCAL_PATHS").MustBool(false) + sec = Cfg.Section("openid") + EnableOpenIDSignIn = sec.Key("ENABLE_OPENID_SIGNIN").MustBool(true) + EnableOpenIDSignUp = sec.Key("ENABLE_OPENID_SIGNUP").MustBool(true) + pats := sec.Key("WHITELISTED_URIS").Strings(" ") + if ( len(pats) != 0 ) { + OpenIDWhitelist = make([]*regexp.Regexp, len(pats)) + for i, p := range pats { + OpenIDWhitelist[i] = regexp.MustCompilePOSIX(p) + } + } + pats = sec.Key("BLACKLISTED_URIS").Strings(" ") + if ( len(pats) != 0 ) { + OpenIDBlacklist = make([]*regexp.Regexp, len(pats)) + for i, p := range pats { + OpenIDBlacklist[i] = regexp.MustCompilePOSIX(p) + } + } + sec = Cfg.Section("attachment") AttachmentPath = sec.Key("PATH").MustString(path.Join(AppDataPath, "attachments")) if !filepath.IsAbs(AttachmentPath) { diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index f66a7ca689..cf322c7f33 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -188,6 +188,14 @@ 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 not correct. If you misplaced your device, use your scratch code to login. twofa_scratch_token_incorrect = Your scratch code is not correct. +login_userpass = User / Password +login_openid = OpenID +openid_connect_submit = Connect +openid_connect_title = Connect to an existing account +openid_connect_desc = The entered OpenID URIs is not know by the system, here you can associate it to an existing account. +openid_register_title = Create new account +openid_register_desc = The entered OpenID URIs is not know by the system, here you can associate it to a new account. +openid_signin_desc = Example URIs: https://anne.me, bob.openid.org.cn, gnusocial.net/carry [mail] activate_account = Please activate your account @@ -239,6 +247,7 @@ repo_name_been_taken = Repository name has already been used. org_name_been_taken = Organization name has already been taken. team_name_been_taken = Team name has already been taken. email_been_used = Email address has already been used. +openid_been_used = OpenID address '%s' has already been used. username_password_incorrect = Username or password is not correct. enterred_invalid_repo_name = Please make sure that the repository name you entered is correct. enterred_invalid_owner_name = Please make sure that the owner name you entered is correct. @@ -315,6 +324,7 @@ password_change_disabled = Non-local users are not allowed to change their passw emails = Email Addresses manage_emails = Manage email addresses +manage_openid = Manage OpenID addresses email_desc = Your primary email address will be used for notifications and other operations. primary = Primary primary_email = Set as primary @@ -322,12 +332,19 @@ delete_email = Delete email_deletion = Email Deletion email_deletion_desc = Deleting this email address will remove all related information from your account. Do you want to continue? email_deletion_success = Email has been deleted successfully! +openid_deletion = OpenID Deletion +openid_deletion_desc = Deleting this OpenID address will prevent you from signing in using it, are you sure you want to continue ? +openid_deletion_success = OpenID has been deleted successfully! add_new_email = Add new email address +add_new_openid = Add new OpenID URI add_email = Add email +add_openid = Add OpenID URI add_email_confirmation_sent = A new confirmation email has been sent to '%s', please check your inbox within the next %d hours to complete the confirmation process. add_email_success = Your new email address was successfully added. +add_openid_success = Your new OpenID address was successfully added. keep_email_private = Keep Email Address Private keep_email_private_popup = Your email address will be hidden from other users if this option is set. +openid_desc = Your OpenID addresses will let you delegate authentication to your provider of choice manage_ssh_keys = Manage SSH Keys add_key = Add Key diff --git a/public/img/openid-16x16.png b/public/img/openid-16x16.png new file mode 100644 index 0000000000000000000000000000000000000000..b3184808423b66f9de8852133609697d34c798ce GIT binary patch literal 230 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!73?$#)eFPGa2=EDUoilItkI9lNRxRJNZ`ZXO zSN~jI_~^;QzyJRTYF{-5s$op>c6VXuV3qX%aySb-B8wRqxP?KOkzv*x37{Z*iKnkC z`$J{{A#L$DB@Pupp%70O#}JO|$r|iU1qx{lkAey{77K_kKC>bq#!~i#L4ux$d4gZM zMv|Alh<=z^vOxg*DoyXO 0 { + ctx.SetCookie("redirect_to", redirectTo, 0, setting.AppSubURL) + } else { + redirectTo, _ = url.QueryUnescape(ctx.GetCookie("redirect_to")) + } + + if isSucceed { + if len(redirectTo) > 0 { + ctx.SetCookie("redirect_to", "", -1, setting.AppSubURL) + ctx.Redirect(redirectTo) + } else { + ctx.Redirect(setting.AppSubURL + "/") + } + return + } + + ctx.Data["PageIsSignIn"] = true + ctx.Data["PageIsLoginOpenID"] = true + ctx.HTML(200, tplSignInOpenID) +} + +// Check if the given OpenID URI is allowed by blacklist/whitelist +func allowedOpenIDURI(uri string) (err error) { + + // In case a Whitelist is present, URI must be in it + // in order to be accepted + if len(setting.OpenIDWhitelist) != 0 { + for _, pat := range setting.OpenIDWhitelist { + if pat.MatchString(uri) { + return nil // pass + } + } + // must match one of this or be refused + return fmt.Errorf("URI not allowed by whitelist") + } + + // A blacklist match expliclty forbids + for _, pat := range setting.OpenIDBlacklist { + if pat.MatchString(uri) { + return fmt.Errorf("URI forbidden by blacklist") + } + } + + return nil +} + +// SignInOpenIDPost response for openid sign in request +func SignInOpenIDPost(ctx *context.Context, form auth.SignInOpenIDForm) { + ctx.Data["Title"] = ctx.Tr("sign_in") + ctx.Data["PageIsSignIn"] = true + ctx.Data["PageIsLoginOpenID"] = true + + if ctx.HasError() { + ctx.HTML(200, tplSignInOpenID) + return + } + + id, err := openid.Normalize(form.Openid) + if err != nil { + ctx.RenderWithErr(err.Error(), tplSignInOpenID, &form) + return; + } + form.Openid = id + + log.Trace("OpenID uri: " + id) + + err = allowedOpenIDURI(id); if err != nil { + ctx.RenderWithErr(err.Error(), tplSignInOpenID, &form) + return; + } + + redirectTo := setting.AppURL + "user/login/openid" + url, err := openid.RedirectURL(id, redirectTo, setting.AppURL) + if err != nil { + ctx.RenderWithErr(err.Error(), tplSignInOpenID, &form) + return; + } + + // Request optional nickname and email info + // NOTE: change to `openid.sreg.required` to require it + url += "&openid.ns.sreg=http%3A%2F%2Fopenid.net%2Fextensions%2Fsreg%2F1.1" + url += "&openid.sreg.optional=nickname%2Cemail" + + log.Trace("Form-passed openid-remember: %s", form.Remember) + ctx.Session.Set("openid_signin_remember", form.Remember) + + ctx.Redirect(url) +} + +// signInOpenIDVerify handles response from OpenID provider +func signInOpenIDVerify(ctx *context.Context) { + + log.Trace("Incoming call to: " + ctx.Req.Request.URL.String()) + + fullURL := setting.AppURL + ctx.Req.Request.URL.String()[1:] + log.Trace("Full URL: " + fullURL) + + var id, err = openid.Verify(fullURL) + if err != nil { + ctx.RenderWithErr(err.Error(), tplSignInOpenID, &auth.SignInOpenIDForm{ + Openid: id, + }) + return + } + + log.Trace("Verified ID: " + id) + + /* Now we should seek for the user and log him in, or prompt + * to register if not found */ + + u, _ := models.GetUserByOpenID(id) + if err != nil { + if ! models.IsErrUserNotExist(err) { + ctx.RenderWithErr(err.Error(), tplSignInOpenID, &auth.SignInOpenIDForm{ + Openid: id, + }) + return + } + } + if u != nil { + log.Trace("User exists, logging in") + remember, _ := ctx.Session.Get("openid_signin_remember").(bool) + log.Trace("Session stored openid-remember: %s", remember) + handleSignIn(ctx, u, remember) + return + } + + log.Trace("User with openid " + id + " does not exist, should connect or register") + + parsedURL, err := url.Parse(fullURL) + if err != nil { + ctx.RenderWithErr(err.Error(), tplSignInOpenID, &auth.SignInOpenIDForm{ + Openid: id, + }) + return + } + values, err := url.ParseQuery(parsedURL.RawQuery) + if err != nil { + ctx.RenderWithErr(err.Error(), tplSignInOpenID, &auth.SignInOpenIDForm{ + Openid: id, + }) + return + } + email := values.Get("openid.sreg.email") + nickname := values.Get("openid.sreg.nickname") + + log.Trace("User has email=" + email + " and nickname=" + nickname) + + if email != "" { + u, _ = models.GetUserByEmail(email) + if err != nil { + if ! models.IsErrUserNotExist(err) { + ctx.RenderWithErr(err.Error(), tplSignInOpenID, &auth.SignInOpenIDForm{ + Openid: id, + }) + return + } + } + if u != nil { + log.Trace("Local user " + u.LowerName + " has OpenID provided email " + email) + } + } + + if u == nil && nickname != "" { + u, _ = models.GetUserByName(nickname) + if err != nil { + if ! models.IsErrUserNotExist(err) { + ctx.RenderWithErr(err.Error(), tplSignInOpenID, &auth.SignInOpenIDForm{ + Openid: id, + }) + return + } + } + if u != nil { + log.Trace("Local user " + u.LowerName + " has OpenID provided nickname " + nickname) + } + } + + ctx.Session.Set("openid_verified_uri", id) + + ctx.Session.Set("openid_determined_email", email) + + if u != nil { + nickname = u.LowerName + } + + ctx.Session.Set("openid_determined_username", nickname) + + if u != nil || ! setting.EnableOpenIDSignUp { + ctx.Redirect(setting.AppSubURL + "/user/openid/connect") + } else { + ctx.Redirect(setting.AppSubURL + "/user/openid/register") + } +} + +// ConnectOpenID shows a form to connect an OpenID URI to an existing account +func ConnectOpenID(ctx *context.Context) { + oid, _ := ctx.Session.Get("openid_verified_uri").(string) + if oid == "" { + ctx.Redirect(setting.AppSubURL + "/user/login/openid") + return + } + ctx.Data["Title"] = "OpenID connect" + ctx.Data["PageIsSignIn"] = true + ctx.Data["PageIsOpenIDConnect"] = true + ctx.Data["EnableOpenIDSignUp"] = setting.EnableOpenIDSignUp + ctx.Data["OpenID"] = oid + userName, _ := ctx.Session.Get("openid_determined_username").(string) + if userName != "" { + ctx.Data["user_name"] = userName + } + ctx.HTML(200, tplConnectOID) +} + +// ConnectOpenIDPost handles submission of a form to connect an OpenID URI to an existing account +func ConnectOpenIDPost(ctx *context.Context, form auth.ConnectOpenIDForm) { + oid, _ := ctx.Session.Get("openid_verified_uri").(string) + if oid == "" { + ctx.Redirect(setting.AppSubURL + "/user/login/openid") + return + } + ctx.Data["Title"] = "OpenID connect" + ctx.Data["PageIsSignIn"] = true + ctx.Data["PageIsOpenIDConnect"] = true + ctx.Data["EnableOpenIDSignUp"] = setting.EnableOpenIDSignUp + ctx.Data["OpenID"] = oid + + u, err := models.UserSignIn(form.UserName, form.Password) + if err != nil { + if models.IsErrUserNotExist(err) { + ctx.RenderWithErr(ctx.Tr("form.username_password_incorrect"), tplConnectOID, &form) + } else { + ctx.Handle(500, "ConnectOpenIDPost", err) + } + return + } + + // add OpenID for the user + userOID := &models.UserOpenID{UID:u.ID, URI:oid} + if err = models.AddUserOpenID(userOID); err != nil { + if models.IsErrOpenIDAlreadyUsed(err) { + ctx.RenderWithErr(ctx.Tr("form.openid_been_used", oid), tplConnectOID, &form) + return + } + ctx.Handle(500, "AddUserOpenID", err) + return + } + + ctx.Flash.Success(ctx.Tr("settings.add_openid_success")) + + remember, _ := ctx.Session.Get("openid_signin_remember").(bool) + log.Trace("Session stored openid-remember: %s", remember) + handleSignIn(ctx, u, remember) +} + +// RegisterOpenID shows a form to create a new user authenticated via an OpenID URI +func RegisterOpenID(ctx *context.Context) { + if ! setting.EnableOpenIDSignUp { + ctx.Error(403) + return + } + oid, _ := ctx.Session.Get("openid_verified_uri").(string) + if oid == "" { + ctx.Redirect(setting.AppSubURL + "/user/login/openid") + return + } + ctx.Data["Title"] = "OpenID signup" + ctx.Data["PageIsSignIn"] = true + ctx.Data["PageIsOpenIDRegister"] = true + ctx.Data["EnableOpenIDSignUp"] = setting.EnableOpenIDSignUp + ctx.Data["OpenID"] = oid + userName, _ := ctx.Session.Get("openid_determined_username").(string) + if userName != "" { + ctx.Data["user_name"] = userName + } + email, _ := ctx.Session.Get("openid_determined_email").(string) + if email != "" { + ctx.Data["email"] = email + } + ctx.HTML(200, tplSignUpOID) +} + +// RegisterOpenIDPost handles submission of a form to create a new user authenticated via an OpenID URI +func RegisterOpenIDPost(ctx *context.Context, form auth.SignUpOpenIDForm) { + if ! setting.EnableOpenIDSignUp { + ctx.Error(403) + return + } + oid, _ := ctx.Session.Get("openid_verified_uri").(string) + if oid == "" { + ctx.Redirect(setting.AppSubURL + "/user/login/openid") + return + } + + ctx.Data["Title"] = "OpenID signup" + ctx.Data["PageIsSignIn"] = true + ctx.Data["PageIsOpenIDRegister"] = true + ctx.Data["EnableOpenIDSignUp"] = setting.EnableOpenIDSignUp + ctx.Data["OpenID"] = oid + +/* + // TODO: handle captcha ? + if setting.Service.EnableCaptcha && !cpt.VerifyReq(ctx.Req) { + ctx.Data["Err_Captcha"] = true + ctx.RenderWithErr(ctx.Tr("form.captcha_incorrect"), tplSignUpOID, &form) + return + } +*/ + + len := setting.MinPasswordLength + if len < 256 { len = 256 } + password, err := base.GetRandomString(len) + if err != nil { + ctx.RenderWithErr(err.Error(), tplSignUpOID, form) + return + } + + // TODO: abstract a finalizeSignUp function ? + u := &models.User{ + Name: form.UserName, + Email: form.Email, + Passwd: password, + IsActive: !setting.Service.RegisterEmailConfirm, + } + if err := models.CreateUser(u); err != nil { + switch { + case models.IsErrUserAlreadyExist(err): + ctx.Data["Err_UserName"] = true + ctx.RenderWithErr(ctx.Tr("form.username_been_taken"), tplSignUpOID, &form) + case models.IsErrEmailAlreadyUsed(err): + ctx.Data["Err_Email"] = true + ctx.RenderWithErr(ctx.Tr("form.email_been_used"), tplSignUpOID, &form) + case models.IsErrNameReserved(err): + ctx.Data["Err_UserName"] = true + ctx.RenderWithErr(ctx.Tr("user.form.name_reserved", err.(models.ErrNameReserved).Name), tplSignUpOID, &form) + case models.IsErrNamePatternNotAllowed(err): + ctx.Data["Err_UserName"] = true + ctx.RenderWithErr(ctx.Tr("user.form.name_pattern_not_allowed", err.(models.ErrNamePatternNotAllowed).Pattern), tplSignUpOID, &form) + default: + ctx.Handle(500, "CreateUser", err) + } + return + } + log.Trace("Account created: %s", u.Name) + + // add OpenID for the user + userOID := &models.UserOpenID{UID:u.ID, URI:oid} + if err = models.AddUserOpenID(userOID); err != nil { + if models.IsErrOpenIDAlreadyUsed(err) { + ctx.RenderWithErr(ctx.Tr("form.openid_been_used", oid), tplSignUpOID, &form) + return + } + ctx.Handle(500, "AddUserOpenID", err) + return + } + + // Auto-set admin for the only user. + if models.CountUsers() == 1 { + u.IsAdmin = true + u.IsActive = true + if err := models.UpdateUser(u); err != nil { + ctx.Handle(500, "UpdateUser", err) + return + } + } + + // Send confirmation email, no need for social account. + if setting.Service.RegisterEmailConfirm && u.ID > 1 { + models.SendActivateAccountMail(ctx.Context, u) + ctx.Data["IsSendRegisterMail"] = true + ctx.Data["Email"] = u.Email + ctx.Data["Hours"] = setting.Service.ActiveCodeLives / 60 + ctx.HTML(200, TplActivate) + + if err := ctx.Cache.Put("MailResendLimit_"+u.LowerName, u.LowerName, 180); err != nil { + log.Error(4, "Set cache(MailResendLimit) fail: %v", err) + } + return + } + + remember, _ := ctx.Session.Get("openid_signin_remember").(bool) + log.Trace("Session stored openid-remember: %s", remember) + handleSignIn(ctx, u, remember) +} diff --git a/routers/user/setting_openid.go b/routers/user/setting_openid.go new file mode 100644 index 0000000000..5e6052d3ef --- /dev/null +++ b/routers/user/setting_openid.go @@ -0,0 +1,142 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package user + +import ( + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/auth" + "code.gitea.io/gitea/modules/auth/openid" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" +) + +const ( + tplSettingsOpenID base.TplName = "user/settings/openid" +) + +// SettingsOpenID renders change user's openid page +func SettingsOpenID(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("settings") + ctx.Data["PageIsSettingsOpenID"] = true + + if ctx.Query("openid.return_to") != "" { + settingsOpenIDVerify(ctx) + return + } + + openid, err := models.GetUserOpenIDs(ctx.User.ID) + if err != nil { + ctx.Handle(500, "GetUserOpenIDs", err) + return + } + ctx.Data["OpenIDs"] = openid + + ctx.HTML(200, tplSettingsOpenID) +} + +// SettingsOpenIDPost response for change user's openid +func SettingsOpenIDPost(ctx *context.Context, form auth.AddOpenIDForm) { + ctx.Data["Title"] = ctx.Tr("settings") + ctx.Data["PageIsSettingsOpenID"] = true + + if ctx.HasError() { + ctx.HTML(200, tplSettingsOpenID) + return + } + + // WARNING: specifying a wrong OpenID here could lock + // a user out of her account, would be better to + // verify/confirm the new OpenID before storing it + + // Also, consider allowing for multiple OpenID URIs + + id, err := openid.Normalize(form.Openid) + if err != nil { + ctx.RenderWithErr(err.Error(), tplSettingsOpenID, &form) + return; + } + form.Openid = id + log.Trace("Normalized id: " + id) + + oids, err := models.GetUserOpenIDs(ctx.User.ID) + if err != nil { + ctx.Handle(500, "GetUserOpenIDs", err) + return + } + ctx.Data["OpenIDs"] = oids + + // Check that the OpenID is not already used + for _, obj := range oids { + if obj.URI == id { + ctx.RenderWithErr(ctx.Tr("form.openid_been_used", id), tplSettingsOpenID, &form) + return + } + } + + + redirectTo := setting.AppURL + "user/settings/openid" + url, err := openid.RedirectURL(id, redirectTo, setting.AppURL) + if err != nil { + ctx.RenderWithErr(err.Error(), tplSettingsOpenID, &form) + return; + } + ctx.Redirect(url) +} + +func settingsOpenIDVerify(ctx *context.Context) { + log.Trace("Incoming call to: " + ctx.Req.Request.URL.String()) + + fullURL := setting.AppURL + ctx.Req.Request.URL.String()[1:] + log.Trace("Full URL: " + fullURL) + + oids, err := models.GetUserOpenIDs(ctx.User.ID) + if err != nil { + ctx.Handle(500, "GetUserOpenIDs", err) + return + } + ctx.Data["OpenIDs"] = oids + + id, err := openid.Verify(fullURL) + if err != nil { + ctx.RenderWithErr(err.Error(), tplSettingsOpenID, &auth.AddOpenIDForm{ + Openid: id, + }) + return + } + + log.Trace("Verified ID: " + id) + + oid := &models.UserOpenID{UID:ctx.User.ID, URI:id} + if err = models.AddUserOpenID(oid); err != nil { + if models.IsErrOpenIDAlreadyUsed(err) { + ctx.RenderWithErr(ctx.Tr("form.openid_been_used", id), tplSettingsOpenID, &auth.AddOpenIDForm{ Openid: id }) + return + } + ctx.Handle(500, "AddUserOpenID", err) + return + } + log.Trace("Associated OpenID %s to user %s", id, ctx.User.Name) + ctx.Flash.Success(ctx.Tr("settings.add_openid_success")) + + ctx.Redirect(setting.AppSubURL + "/user/settings/openid") +} + +// DeleteOpenID response for delete user's openid +func DeleteOpenID(ctx *context.Context) { + if err := models.DeleteUserOpenID(&models.UserOpenID{ID: ctx.QueryInt64("id"), UID: ctx.User.ID}); err != nil { + ctx.Handle(500, "DeleteUserOpenID", err) + return + } + log.Trace("OpenID address deleted: %s", ctx.User.Name) + + ctx.Flash.Success(ctx.Tr("settings.openid_deletion_success")) + ctx.JSON(200, map[string]interface{}{ + "redirect": setting.AppSubURL + "/user/settings/openid", + }) +} + diff --git a/templates/user/auth/finalize_openid.tmpl b/templates/user/auth/finalize_openid.tmpl new file mode 100644 index 0000000000..d318d33245 --- /dev/null +++ b/templates/user/auth/finalize_openid.tmpl @@ -0,0 +1,46 @@ +{{template "base/head" .}} +