diff --git a/cmd/web.go b/cmd/web.go index 0410ad519..17674b306 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 8e29e39b1..c2d41b853 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 62529f83f..68bc23890 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 bf188dc4c..4f1254b96 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 000000000..efde68410 --- /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 bba4446db..2ae6e355f 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 ff898573a..ad303d753 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 000000000..a5c88e900 --- /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 000000000..cf9f5ae70 --- /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 000000000..9de65a57b --- /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 000000000..aebdf1515 --- /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 32987e6d3..9c6e38c46 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 000000000..582c6dc69 --- /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 fa53b484e..52e50af6a 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 520dc429d..0ac63d691 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 f66a7ca68..cf322c7f3 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 000000000..b31848084 Binary files /dev/null and b/public/img/openid-16x16.png differ diff --git a/routers/user/auth.go b/routers/user/auth.go index f8c6db126..4827f38b5 100644 --- a/routers/user/auth.go +++ b/routers/user/auth.go @@ -107,7 +107,6 @@ func checkAutoLogin(ctx *context.Context) bool { // SignIn render sign in page func SignIn(ctx *context.Context) { - ctx.Data["Title"] = ctx.Tr("sign_in") // Check auto-login. if checkAutoLogin(ctx) { @@ -120,6 +119,9 @@ func SignIn(ctx *context.Context) { return } ctx.Data["OAuth2Providers"] = oauth2Providers + ctx.Data["Title"] = ctx.Tr("sign_in") + ctx.Data["PageIsSignIn"] = true + ctx.Data["PageIsLogin"] = true ctx.HTML(200, tplSignIn) } @@ -127,6 +129,8 @@ func SignIn(ctx *context.Context) { // SignInPost response for sign in request func SignInPost(ctx *context.Context, form auth.SignInForm) { ctx.Data["Title"] = ctx.Tr("sign_in") + ctx.Data["PageIsSignIn"] = true + ctx.Data["PageIsLogin"] = true oauth2Providers, err := models.GetActiveOAuth2Providers() if err != nil { @@ -316,6 +320,10 @@ func handleSignInFull(ctx *context.Context, u *models.User, remember bool, obeyR setting.CookieRememberName, u.Name, days, setting.AppSubURL) } + ctx.Session.Delete("openid_verified_uri") + ctx.Session.Delete("openid_signin_remember") + ctx.Session.Delete("openid_determined_email") + ctx.Session.Delete("openid_determined_username") ctx.Session.Delete("twofaUid") ctx.Session.Delete("twofaRemember") ctx.Session.Set("uid", u.ID) diff --git a/routers/user/auth_openid.go b/routers/user/auth_openid.go new file mode 100644 index 000000000..ebcfa7665 --- /dev/null +++ b/routers/user/auth_openid.go @@ -0,0 +1,426 @@ +// 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 ( + "fmt" + "net/url" + + "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 ( + tplSignInOpenID base.TplName = "user/auth/signin_openid" + tplConnectOID base.TplName = "user/auth/signup_openid_connect" + tplSignUpOID base.TplName = "user/auth/signup_openid_register" +) + +// SignInOpenID render sign in page +func SignInOpenID(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("sign_in") + + if ctx.Query("openid.return_to") != "" { + signInOpenIDVerify(ctx) + return + } + + // Check auto-login. + isSucceed, err := AutoSignIn(ctx) + if err != nil { + ctx.Handle(500, "AutoSignIn", err) + return + } + + redirectTo := ctx.Query("redirect_to") + if len(redirectTo) > 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 000000000..5e6052d3e --- /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 000000000..d318d3324 --- /dev/null +++ b/templates/user/auth/finalize_openid.tmpl @@ -0,0 +1,46 @@ +{{template "base/head" .}} +
+
+
+ {{template "user/auth/finalize_openid_navbar" .}} +
+ {{template "base/alert" .}} +

+ {{.i18n.Tr "auth.login_userpass"}} +

+
+
+ {{.CsrfTokenHtml}} +
+ + +
+
+ + +
+
+ +
+ + +
+
+ +
+ + + {{.i18n.Tr "auth.forget_password"}} +
+ {{if .ShowRegistrationButton}} + + {{end}} +
+ +
+
+
+{{template "base/footer" .}} diff --git a/templates/user/auth/signin.tmpl b/templates/user/auth/signin.tmpl index 5ed8612e3..bbb471cac 100644 --- a/templates/user/auth/signin.tmpl +++ b/templates/user/auth/signin.tmpl @@ -1,3 +1,8 @@ {{template "base/head" .}} -{{template "user/auth/signin_inner" .}} +
+ {{template "user/auth/signin_navbar" .}} +
+ {{template "user/auth/signin_inner" .}} +
+
{{template "base/footer" .}} diff --git a/templates/user/auth/signin_inner.tmpl b/templates/user/auth/signin_inner.tmpl index 91feb7c52..c8df0870b 100644 --- a/templates/user/auth/signin_inner.tmpl +++ b/templates/user/auth/signin_inner.tmpl @@ -1,57 +1,51 @@ -
-
-
+ {{if or (not .LinkAccountMode) (and .LinkAccountMode .LinkAccountModeSignIn)}} + {{template "base/alert" .}} + {{end}} +

+ {{.i18n.Tr "auth.login_userpass"}} +

+
- {{.CsrfTokenHtml}} -

- {{.i18n.Tr "sign_in"}} -

-
- {{if or (not .LinkAccountMode) (and .LinkAccountMode .LinkAccountModeSignIn)}} - {{template "base/alert" .}} - {{end}} -
- - -
-
- - -
- {{if not .LinkAccountMode}} -
- -
- - -
-
- {{end}} + {{.CsrfTokenHtml}} +
+ + +
+
+ + +
+ {{if not .LinkAccountMode}} +
+ +
+ + +
+
+ {{end}} -
- - - {{.i18n.Tr "auth.forgot_password"}} -
+
+ + + {{.i18n.Tr "auth.forgot_password"}} +
- {{if .ShowRegistrationButton}} - - {{end}} + {{if .ShowRegistrationButton}} + + {{end}} - {{if .OAuth2Providers}} -
-
-
-

{{.i18n.Tr "sign_in_with"}}

{{range $key, $value := .OAuth2Providers}}{{$value.DisplayName}}{{end}} -
-
+ {{if .OAuth2Providers}} +
+
+
+

{{.i18n.Tr "sign_in_with"}}

{{range $key, $value := .OAuth2Providers}}{{$value.DisplayName}}{{end}}
- {{end}}
+
+ {{end}}
-
-
diff --git a/templates/user/auth/signin_navbar.tmpl b/templates/user/auth/signin_navbar.tmpl new file mode 100644 index 000000000..2bee1325f --- /dev/null +++ b/templates/user/auth/signin_navbar.tmpl @@ -0,0 +1,11 @@ + diff --git a/templates/user/auth/signin_openid.tmpl b/templates/user/auth/signin_openid.tmpl new file mode 100644 index 000000000..ccc689ce3 --- /dev/null +++ b/templates/user/auth/signin_openid.tmpl @@ -0,0 +1,37 @@ +{{template "base/head" .}} + +{{template "base/footer" .}} diff --git a/templates/user/auth/signup_openid_connect.tmpl b/templates/user/auth/signup_openid_connect.tmpl new file mode 100644 index 000000000..476141e0d --- /dev/null +++ b/templates/user/auth/signup_openid_connect.tmpl @@ -0,0 +1,45 @@ +{{template "base/head" .}} + +{{template "base/footer" .}} diff --git a/templates/user/auth/signup_openid_navbar.tmpl b/templates/user/auth/signup_openid_navbar.tmpl new file mode 100644 index 000000000..86f6ee7d8 --- /dev/null +++ b/templates/user/auth/signup_openid_navbar.tmpl @@ -0,0 +1,11 @@ + + diff --git a/templates/user/auth/signup_openid_register.tmpl b/templates/user/auth/signup_openid_register.tmpl new file mode 100644 index 000000000..4970494da --- /dev/null +++ b/templates/user/auth/signup_openid_register.tmpl @@ -0,0 +1,34 @@ +{{template "base/head" .}} + +{{template "base/footer" .}} diff --git a/templates/user/settings/navbar.tmpl b/templates/user/settings/navbar.tmpl index 611c091ba..d798d4021 100644 --- a/templates/user/settings/navbar.tmpl +++ b/templates/user/settings/navbar.tmpl @@ -11,6 +11,11 @@ {{.i18n.Tr "settings.emails"}} + {{if .EnableOpenIDSignIn}} + + OpenID + + {{end}} {{.i18n.Tr "settings.ssh_keys"}} @@ -26,4 +31,4 @@ {{.i18n.Tr "settings.delete"}} -
\ No newline at end of file +
diff --git a/templates/user/settings/openid.tmpl b/templates/user/settings/openid.tmpl new file mode 100644 index 000000000..0528cbaa1 --- /dev/null +++ b/templates/user/settings/openid.tmpl @@ -0,0 +1,57 @@ +{{template "base/head" .}} +
+
+
+ {{template "user/settings/navbar" .}} +
+ {{template "base/alert" .}} +

+ {{.i18n.Tr "settings.manage_openid"}} +

+
+
+
+ {{.i18n.Tr "settings.openid_desc"}} +
+ {{range .OpenIDs}} +
+
+ {{.URI}} +
+ +
+
+
+ {{end}} +
+
+
+
+ {{.CsrfTokenHtml}} +
+ + +
+ +
+
+
+
+
+
+ + +{{template "base/footer" .}} diff --git a/vendor/github.com/yohcop/openid-go/LICENSE b/vendor/github.com/yohcop/openid-go/LICENSE new file mode 100644 index 000000000..4e8c3333b --- /dev/null +++ b/vendor/github.com/yohcop/openid-go/LICENSE @@ -0,0 +1,13 @@ +Copyright 2015 Yohann Coppel + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/vendor/github.com/yohcop/openid-go/README.md b/vendor/github.com/yohcop/openid-go/README.md new file mode 100644 index 000000000..f617126d2 --- /dev/null +++ b/vendor/github.com/yohcop/openid-go/README.md @@ -0,0 +1,38 @@ +# openid.go + +This is a consumer (Relying party) implementation of OpenId 2.0, +written in Go. + + go get -u github.com/yohcop/openid-go + +[![Build Status](https://travis-ci.org/yohcop/openid-go.svg?branch=master)](https://travis-ci.org/yohcop/openid-go) + +## Github + +Be awesome! Feel free to clone and use according to the licence. +If you make a useful change that can benefit others, send a +pull request! This ensures that one version has all the good stuff +and doesn't fall behind. + +## Code example + +See `_example/` for a simple webserver using the openID +implementation. Also, read the comment about the NonceStore towards +the top of that file. The example must be run for the openid-go +directory, like so: + + go run _example/server.go + +## App Engine + +In order to use this on Google App Engine, you need to create an instance with a custom `*http.Client` provided by [urlfetch](https://cloud.google.com/appengine/docs/go/urlfetch/). + +```go +oid := openid.NewOpenID(urlfetch.Client(appengine.NewContext(r))) +oid.RedirectURL(...) +oid.Verify(...) +``` + +## License + +Distributed under the [Apache v2.0 license](http://www.apache.org/licenses/LICENSE-2.0.html). diff --git a/vendor/github.com/yohcop/openid-go/discover.go b/vendor/github.com/yohcop/openid-go/discover.go new file mode 100644 index 000000000..2943b9cd1 --- /dev/null +++ b/vendor/github.com/yohcop/openid-go/discover.go @@ -0,0 +1,57 @@ +package openid + +// 7.3.1. Discovered Information +// Upon successful completion of discovery, the Relying Party will +// have one or more sets of the following information (see the +// Terminology section for definitions). If more than one set of the +// following information has been discovered, the precedence rules +// defined in [XRI_Resolution_2.0] are to be applied. +// - OP Endpoint URL +// - Protocol Version +// If the end user did not enter an OP Identifier, the following +// information will also be present: +// - Claimed Identifier +// - OP-Local Identifier +// If the end user entered an OP Identifier, there is no Claimed +// Identifier. For the purposes of making OpenID Authentication +// requests, the value +// "http://specs.openid.net/auth/2.0/identifier_select" MUST be +// used as both the Claimed Identifier and the OP-Local Identifier +// when an OP Identifier is entered. +func Discover(id string) (opEndpoint, opLocalID, claimedID string, err error) { + return defaultInstance.Discover(id) +} + +func (oid *OpenID) Discover(id string) (opEndpoint, opLocalID, claimedID string, err error) { + // From OpenID specs, 7.2: Normalization + if id, err = Normalize(id); err != nil { + return + } + + // From OpenID specs, 7.3: Discovery. + + // If the identifier is an XRI, [XRI_Resolution_2.0] will yield an + // XRDS document that contains the necessary information. It + // should also be noted that Relying Parties can take advantage of + // XRI Proxy Resolvers, such as the one provided by XDI.org at + // http://www.xri.net. This will remove the need for the RPs to + // perform XRI Resolution locally. + + // XRI not supported. + + // If it is a URL, the Yadis protocol [Yadis] SHALL be first + // attempted. If it succeeds, the result is again an XRDS + // document. + if opEndpoint, opLocalID, err = yadisDiscovery(id, oid.urlGetter); err != nil { + // If the Yadis protocol fails and no valid XRDS document is + // retrieved, or no Service Elements are found in the XRDS + // document, the URL is retrieved and HTML-Based discovery SHALL be + // attempted. + opEndpoint, opLocalID, claimedID, err = htmlDiscovery(id, oid.urlGetter) + } + + if err != nil { + return "", "", "", err + } + return +} diff --git a/vendor/github.com/yohcop/openid-go/discovery_cache.go b/vendor/github.com/yohcop/openid-go/discovery_cache.go new file mode 100644 index 000000000..5fe2e99cc --- /dev/null +++ b/vendor/github.com/yohcop/openid-go/discovery_cache.go @@ -0,0 +1,69 @@ +package openid + +import ( + "sync" +) + +type DiscoveredInfo interface { + OpEndpoint() string + OpLocalID() string + ClaimedID() string + // ProtocolVersion: it's always openId 2. +} + +type DiscoveryCache interface { + Put(id string, info DiscoveredInfo) + // Return a discovered info, or nil. + Get(id string) DiscoveredInfo +} + +type SimpleDiscoveredInfo struct { + opEndpoint string + opLocalID string + claimedID string +} + +func (s *SimpleDiscoveredInfo) OpEndpoint() string { + return s.opEndpoint +} + +func (s *SimpleDiscoveredInfo) OpLocalID() string { + return s.opLocalID +} + +func (s *SimpleDiscoveredInfo) ClaimedID() string { + return s.claimedID +} + +type SimpleDiscoveryCache struct { + cache map[string]DiscoveredInfo + mutex *sync.Mutex +} + +func NewSimpleDiscoveryCache() *SimpleDiscoveryCache { + return &SimpleDiscoveryCache{cache: map[string]DiscoveredInfo{}, mutex: &sync.Mutex{}} +} + +func (s *SimpleDiscoveryCache) Put(id string, info DiscoveredInfo) { + s.mutex.Lock() + defer s.mutex.Unlock() + + s.cache[id] = info +} + +func (s *SimpleDiscoveryCache) Get(id string) DiscoveredInfo { + s.mutex.Lock() + defer s.mutex.Unlock() + + if info, has := s.cache[id]; has { + return info + } + return nil +} + +func compareDiscoveredInfo(a DiscoveredInfo, opEndpoint, opLocalID, claimedID string) bool { + return a != nil && + a.OpEndpoint() == opEndpoint && + a.OpLocalID() == opLocalID && + a.ClaimedID() == claimedID +} diff --git a/vendor/github.com/yohcop/openid-go/getter.go b/vendor/github.com/yohcop/openid-go/getter.go new file mode 100644 index 000000000..a21019f0f --- /dev/null +++ b/vendor/github.com/yohcop/openid-go/getter.go @@ -0,0 +1,31 @@ +package openid + +import ( + "net/http" + "net/url" +) + +// Interface that simplifies testing. +type httpGetter interface { + Get(uri string, headers map[string]string) (resp *http.Response, err error) + Post(uri string, form url.Values) (resp *http.Response, err error) +} + +type defaultGetter struct { + client *http.Client +} + +func (dg *defaultGetter) Get(uri string, headers map[string]string) (resp *http.Response, err error) { + request, err := http.NewRequest("GET", uri, nil) + if err != nil { + return + } + for h, v := range headers { + request.Header.Add(h, v) + } + return dg.client.Do(request) +} + +func (dg *defaultGetter) Post(uri string, form url.Values) (resp *http.Response, err error) { + return dg.client.PostForm(uri, form) +} diff --git a/vendor/github.com/yohcop/openid-go/html_discovery.go b/vendor/github.com/yohcop/openid-go/html_discovery.go new file mode 100644 index 000000000..145e6c4d9 --- /dev/null +++ b/vendor/github.com/yohcop/openid-go/html_discovery.go @@ -0,0 +1,77 @@ +package openid + +import ( + "errors" + "io" + + "golang.org/x/net/html" +) + +func htmlDiscovery(id string, getter httpGetter) (opEndpoint, opLocalID, claimedID string, err error) { + resp, err := getter.Get(id, nil) + if err != nil { + return "", "", "", err + } + opEndpoint, opLocalID, err = findProviderFromHeadLink(resp.Body) + return opEndpoint, opLocalID, resp.Request.URL.String(), err +} + +func findProviderFromHeadLink(input io.Reader) (opEndpoint, opLocalID string, err error) { + tokenizer := html.NewTokenizer(input) + inHead := false + for { + tt := tokenizer.Next() + switch tt { + case html.ErrorToken: + // Even if the document is malformed after we found a + // valid tag, ignore and let's be happy with our + // openid2.provider and potentially openid2.local_id as well. + if len(opEndpoint) > 0 { + return + } + return "", "", tokenizer.Err() + case html.StartTagToken, html.EndTagToken, html.SelfClosingTagToken: + tk := tokenizer.Token() + if tk.Data == "head" { + if tt == html.StartTagToken { + inHead = true + } else { + if len(opEndpoint) > 0 { + return + } + return "", "", errors.New( + "LINK with rel=openid2.provider not found") + } + } else if inHead && tk.Data == "link" { + provider := false + localID := false + href := "" + for _, attr := range tk.Attr { + if attr.Key == "rel" { + if attr.Val == "openid2.provider" { + provider = true + } else if attr.Val == "openid2.local_id" { + localID = true + } else if attr.Val == "openid.server" { + provider = true + } + } else if attr.Key == "href" { + href = attr.Val + } + } + if provider && !localID && len(href) > 0 { + opEndpoint = href + } else if !provider && localID && len(href) > 0 { + opLocalID = href + } + } + } + } + // At this point we should probably have returned either from + // a closing or a tokenizer error (no found). + // But just in case. + if len(opEndpoint) > 0 { + return + } + return "", "", errors.New("LINK rel=openid2.provider not found") +} diff --git a/vendor/github.com/yohcop/openid-go/nonce_store.go b/vendor/github.com/yohcop/openid-go/nonce_store.go new file mode 100644 index 000000000..684a1acb0 --- /dev/null +++ b/vendor/github.com/yohcop/openid-go/nonce_store.go @@ -0,0 +1,87 @@ +package openid + +import ( + "errors" + "flag" + "fmt" + "sync" + "time" +) + +var maxNonceAge = flag.Duration("openid-max-nonce-age", + 60*time.Second, + "Maximum accepted age for openid nonces. The bigger, the more"+ + "memory is needed to store used nonces.") + +type NonceStore interface { + // Returns nil if accepted, an error otherwise. + Accept(endpoint, nonce string) error +} + +type Nonce struct { + T time.Time + S string +} + +type SimpleNonceStore struct { + store map[string][]*Nonce + mutex *sync.Mutex +} + +func NewSimpleNonceStore() *SimpleNonceStore { + return &SimpleNonceStore{store: map[string][]*Nonce{}, mutex: &sync.Mutex{}} +} + +func (d *SimpleNonceStore) Accept(endpoint, nonce string) error { + // Value: A string 255 characters or less in length, that MUST be + // unique to this particular successful authentication response. + if len(nonce) < 20 || len(nonce) > 256 { + return errors.New("Invalid nonce") + } + + // The nonce MUST start with the current time on the server, and MAY + // contain additional ASCII characters in the range 33-126 inclusive + // (printable non-whitespace characters), as necessary to make each + // response unique. The date and time MUST be formatted as specified in + // section 5.6 of [RFC3339], with the following restrictions: + + // All times must be in the UTC timezone, indicated with a "Z". No + // fractional seconds are allowed For example: + // 2005-05-15T17:11:51ZUNIQUE + ts, err := time.Parse(time.RFC3339, nonce[0:20]) + if err != nil { + return err + } + now := time.Now() + diff := now.Sub(ts) + if diff > *maxNonceAge { + return fmt.Errorf("Nonce too old: %ds", diff.Seconds()) + } + + s := nonce[20:] + + // Meh.. now we have to use a mutex, to protect that map from + // concurrent access. Could put a go routine in charge of it + // though. + d.mutex.Lock() + defer d.mutex.Unlock() + + if nonces, hasOp := d.store[endpoint]; hasOp { + // Delete old nonces while we are at it. + newNonces := []*Nonce{{ts, s}} + for _, n := range nonces { + if n.T == ts && n.S == s { + // If return early, just ignore the filtered list + // we have been building so far... + return errors.New("Nonce already used") + } + if now.Sub(n.T) < *maxNonceAge { + newNonces = append(newNonces, n) + } + } + d.store[endpoint] = newNonces + } else { + d.store[endpoint] = []*Nonce{{ts, s}} + } + return nil +} diff --git a/vendor/github.com/yohcop/openid-go/normalizer.go b/vendor/github.com/yohcop/openid-go/normalizer.go new file mode 100644 index 000000000..8c7aa34a6 --- /dev/null +++ b/vendor/github.com/yohcop/openid-go/normalizer.go @@ -0,0 +1,64 @@ +package openid + +import ( + "errors" + "net/url" + "strings" +) + +func Normalize(id string) (string, error) { + id = strings.TrimSpace(id) + if len(id) == 0 { + return "", errors.New("No id provided") + } + + // 7.2 from openID 2.0 spec. + + //If the user's input starts with the "xri://" prefix, it MUST be + //stripped off, so that XRIs are used in the canonical form. + if strings.HasPrefix(id, "xri://") { + id = id[6:] + return id, errors.New("XRI identifiers not supported") + } + + // If the first character of the resulting string is an XRI + // Global Context Symbol ("=", "@", "+", "$", "!") or "(", as + // defined in Section 2.2.1 of [XRI_Syntax_2.0], then the input + // SHOULD be treated as an XRI. + if b := id[0]; b == '=' || b == '@' || b == '+' || b == '$' || b == '!' { + return id, errors.New("XRI identifiers not supported") + } + + // Otherwise, the input SHOULD be treated as an http URL; if it + // does not include a "http" or "https" scheme, the Identifier + // MUST be prefixed with the string "http://". If the URL + // contains a fragment part, it MUST be stripped off together + // with the fragment delimiter character "#". See Section 11.5.2 for + // more information. + if !strings.HasPrefix(id, "http://") && !strings.HasPrefix(id, + "https://") { + id = "http://" + id + } + if fragmentIndex := strings.Index(id, "#"); fragmentIndex != -1 { + id = id[0:fragmentIndex] + } + if u, err := url.ParseRequestURI(id); err != nil { + return "", err + } else { + if u.Host == "" { + return "", errors.New("Invalid address provided as id") + } + if u.Path == "" { + u.Path = "/" + } + id = u.String() + } + + // URL Identifiers MUST then be further normalized by both + // following redirects when retrieving their content and finally + // applying the rules in Section 6 of [RFC3986] to the final + // destination URL. This final URL MUST be noted by the Relying + // Party as the Claimed Identifier and be used when requesting + // authentication. + return id, nil +} diff --git a/vendor/github.com/yohcop/openid-go/openid.go b/vendor/github.com/yohcop/openid-go/openid.go new file mode 100644 index 000000000..5878a478e --- /dev/null +++ b/vendor/github.com/yohcop/openid-go/openid.go @@ -0,0 +1,15 @@ +package openid + +import ( + "net/http" +) + +type OpenID struct { + urlGetter httpGetter +} + +func NewOpenID(client *http.Client) *OpenID { + return &OpenID{urlGetter: &defaultGetter{client: client}} +} + +var defaultInstance = NewOpenID(http.DefaultClient) diff --git a/vendor/github.com/yohcop/openid-go/redirect.go b/vendor/github.com/yohcop/openid-go/redirect.go new file mode 100644 index 000000000..9af7a845b --- /dev/null +++ b/vendor/github.com/yohcop/openid-go/redirect.go @@ -0,0 +1,55 @@ +package openid + +import ( + "net/url" + "strings" +) + +func RedirectURL(id, callbackURL, realm string) (string, error) { + return defaultInstance.RedirectURL(id, callbackURL, realm) +} + +func (oid *OpenID) RedirectURL(id, callbackURL, realm string) (string, error) { + opEndpoint, opLocalID, claimedID, err := oid.Discover(id) + if err != nil { + return "", err + } + return BuildRedirectURL(opEndpoint, opLocalID, claimedID, callbackURL, realm) +} + +func BuildRedirectURL(opEndpoint, opLocalID, claimedID, returnTo, realm string) (string, error) { + values := make(url.Values) + values.Add("openid.ns", "http://specs.openid.net/auth/2.0") + values.Add("openid.mode", "checkid_setup") + values.Add("openid.return_to", returnTo) + + // 9.1. Request Parameters + // "openid.claimed_id" and "openid.identity" SHALL be either both present or both absent. + if len(claimedID) > 0 { + values.Add("openid.claimed_id", claimedID) + if len(opLocalID) > 0 { + values.Add("openid.identity", opLocalID) + } else { + // If a different OP-Local Identifier is not specified, + // the claimed identifier MUST be used as the value for openid.identity. + values.Add("openid.identity", claimedID) + } + } else { + // 7.3.1. Discovered Information + // If the end user entered an OP Identifier, there is no Claimed Identifier. + // For the purposes of making OpenID Authentication requests, the value + // "http://specs.openid.net/auth/2.0/identifier_select" MUST be used as both the + // Claimed Identifier and the OP-Local Identifier when an OP Identifier is entered. + values.Add("openid.claimed_id", "http://specs.openid.net/auth/2.0/identifier_select") + values.Add("openid.identity", "http://specs.openid.net/auth/2.0/identifier_select") + } + + if len(realm) > 0 { + values.Add("openid.realm", realm) + } + + if strings.Contains(opEndpoint, "?") { + return opEndpoint + "&" + values.Encode(), nil + } + return opEndpoint + "?" + values.Encode(), nil +} diff --git a/vendor/github.com/yohcop/openid-go/verify.go b/vendor/github.com/yohcop/openid-go/verify.go new file mode 100644 index 000000000..6090ecac5 --- /dev/null +++ b/vendor/github.com/yohcop/openid-go/verify.go @@ -0,0 +1,250 @@ +package openid + +import ( + "errors" + "fmt" + "io/ioutil" + "net/url" + "strings" +) + +func Verify(uri string, cache DiscoveryCache, nonceStore NonceStore) (id string, err error) { + return defaultInstance.Verify(uri, cache, nonceStore) +} + +func (oid *OpenID) Verify(uri string, cache DiscoveryCache, nonceStore NonceStore) (id string, err error) { + parsedURL, err := url.Parse(uri) + if err != nil { + return "", err + } + values, err := url.ParseQuery(parsedURL.RawQuery) + if err != nil { + return "", err + } + + // 11. Verifying Assertions + // When the Relying Party receives a positive assertion, it MUST + // verify the following before accepting the assertion: + + // - The value of "openid.signed" contains all the required fields. + // (Section 10.1) + if err = verifySignedFields(values); err != nil { + return "", err + } + + // - The signature on the assertion is valid (Section 11.4) + if err = verifySignature(uri, values, oid.urlGetter); err != nil { + return "", err + } + + // - The value of "openid.return_to" matches the URL of the current + // request (Section 11.1) + if err = verifyReturnTo(parsedURL, values); err != nil { + return "", err + } + + // - Discovered information matches the information in the assertion + // (Section 11.2) + if err = oid.verifyDiscovered(parsedURL, values, cache); err != nil { + return "", err + } + + // - An assertion has not yet been accepted from this OP with the + // same value for "openid.response_nonce" (Section 11.3) + if err = verifyNonce(values, nonceStore); err != nil { + return "", err + } + + // If all four of these conditions are met, assertion is now + // verified. If the assertion contained a Claimed Identifier, the + // user is now authenticated with that identifier. + return values.Get("openid.claimed_id"), nil +} + +// 10.1. Positive Assertions +// openid.signed - Comma-separated list of signed fields. +// This entry consists of the fields without the "openid." prefix that the signature covers. +// This list MUST contain at least "op_endpoint", "return_to" "response_nonce" and "assoc_handle", +// and if present in the response, "claimed_id" and "identity". +func verifySignedFields(vals url.Values) error { + ok := map[string]bool{ + "op_endpoint": false, + "return_to": false, + "response_nonce": false, + "assoc_handle": false, + "claimed_id": vals.Get("openid.claimed_id") == "", + "identity": vals.Get("openid.identity") == "", + } + signed := strings.Split(vals.Get("openid.signed"), ",") + for _, sf := range signed { + ok[sf] = true + } + for k, v := range ok { + if !v { + return fmt.Errorf("%v must be signed but isn't", k) + } + } + return nil +} + +// 11.1. Verifying the Return URL +// To verify that the "openid.return_to" URL matches the URL that is processing this assertion: +// - The URL scheme, authority, and path MUST be the same between the two +// URLs. +// - Any query parameters that are present in the "openid.return_to" URL +// MUST also be present with the same values in the URL of the HTTP +// request the RP received. +func verifyReturnTo(uri *url.URL, vals url.Values) error { + returnTo := vals.Get("openid.return_to") + rp, err := url.Parse(returnTo) + if err != nil { + return err + } + if uri.Scheme != rp.Scheme || + uri.Host != rp.Host || + uri.Path != rp.Path { + return errors.New( + "Scheme, host or path don't match in return_to URL") + } + qp, err := url.ParseQuery(rp.RawQuery) + if err != nil { + return err + } + return compareQueryParams(qp, vals) +} + +// Any parameter in q1 must also be present in q2, and values must match. +func compareQueryParams(q1, q2 url.Values) error { + for k := range q1 { + v1 := q1.Get(k) + v2 := q2.Get(k) + if v1 != v2 { + return fmt.Errorf( + "URLs query params don't match: Param %s different: %s vs %s", + k, v1, v2) + } + } + return nil +} + +func (oid *OpenID) verifyDiscovered(uri *url.URL, vals url.Values, cache DiscoveryCache) error { + version := vals.Get("openid.ns") + if version != "http://specs.openid.net/auth/2.0" { + return errors.New("Bad protocol version") + } + + endpoint := vals.Get("openid.op_endpoint") + if len(endpoint) == 0 { + return errors.New("missing openid.op_endpoint url param") + } + localID := vals.Get("openid.identity") + if len(localID) == 0 { + return errors.New("no localId to verify") + } + claimedID := vals.Get("openid.claimed_id") + if len(claimedID) == 0 { + // If no Claimed Identifier is present in the response, the + // assertion is not about an identifier and the RP MUST NOT use the + // User-supplied Identifier associated with the current OpenID + // authentication transaction to identify the user. Extension + // information in the assertion MAY still be used. + // --- This library does not support this case. So claimed + // identifier must be present. + return errors.New("no claimed_id to verify") + } + + // 11.2. Verifying Discovered Information + + // If the Claimed Identifier in the assertion is a URL and contains a + // fragment, the fragment part and the fragment delimiter character "#" + // MUST NOT be used for the purposes of verifying the discovered + // information. + claimedIDVerify := claimedID + if fragmentIndex := strings.Index(claimedID, "#"); fragmentIndex != -1 { + claimedIDVerify = claimedID[0:fragmentIndex] + } + + // If the Claimed Identifier is included in the assertion, it + // MUST have been discovered by the Relying Party and the + // information in the assertion MUST be present in the + // discovered information. The Claimed Identifier MUST NOT be an + // OP Identifier. + if discovered := cache.Get(claimedIDVerify); discovered != nil && + discovered.OpEndpoint() == endpoint && + discovered.OpLocalID() == localID && + discovered.ClaimedID() == claimedIDVerify { + return nil + } + + // If the Claimed Identifier was not previously discovered by the + // Relying Party (the "openid.identity" in the request was + // "http://specs.openid.net/auth/2.0/identifier_select" or a different + // Identifier, or if the OP is sending an unsolicited positive + // assertion), the Relying Party MUST perform discovery on the Claimed + // Identifier in the response to make sure that the OP is authorized to + // make assertions about the Claimed Identifier. + if ep, _, _, err := oid.Discover(claimedID); err == nil { + if ep == endpoint { + // This claimed ID points to the same endpoint, therefore this + // endpoint is authorized to make assertions about that claimed ID. + // TODO: There may be multiple endpoints found during discovery. + // They should all be checked. + cache.Put(claimedIDVerify, &SimpleDiscoveredInfo{opEndpoint: endpoint, opLocalID: localID, claimedID: claimedIDVerify}) + return nil + } + } + + return errors.New("Could not verify the claimed ID") +} + +func verifyNonce(vals url.Values, store NonceStore) error { + nonce := vals.Get("openid.response_nonce") + endpoint := vals.Get("openid.op_endpoint") + return store.Accept(endpoint, nonce) +} + +func verifySignature(uri string, vals url.Values, getter httpGetter) error { + // To have the signature verification performed by the OP, the + // Relying Party sends a direct request to the OP. To verify the + // signature, the OP uses a private association that was generated + // when it issued the positive assertion. + + // 11.4.2.1. Request Parameters + params := make(url.Values) + // openid.mode: Value: "check_authentication" + params.Add("openid.mode", "check_authentication") + // Exact copies of all fields from the authentication response, + // except for "openid.mode". + for k, vs := range vals { + if k == "openid.mode" { + continue + } + for _, v := range vs { + params.Add(k, v) + } + } + resp, err := getter.Post(vals.Get("openid.op_endpoint"), params) + if err != nil { + return err + } + defer resp.Body.Close() + content, err := ioutil.ReadAll(resp.Body) + response := string(content) + lines := strings.Split(response, "\n") + + isValid := false + nsValid := false + for _, l := range lines { + if l == "is_valid:true" { + isValid = true + } else if l == "ns:http://specs.openid.net/auth/2.0" { + nsValid = true + } + } + if isValid && nsValid { + // Yay ! + return nil + } + + return errors.New("Could not verify assertion with provider") +} diff --git a/vendor/github.com/yohcop/openid-go/xrds.go b/vendor/github.com/yohcop/openid-go/xrds.go new file mode 100644 index 000000000..34ecf046f --- /dev/null +++ b/vendor/github.com/yohcop/openid-go/xrds.go @@ -0,0 +1,83 @@ +package openid + +import ( + "encoding/xml" + "errors" + "strings" +) + +// TODO: As per 11.2 in openid 2 specs, a service may have multiple +// URIs. We don't care for discovery really, but we do care for +// verification though. +type XrdsIdentifier struct { + Type []string `xml:"Type"` + URI string `xml:"URI"` + LocalID string `xml:"LocalID"` + Priority int `xml:"priority,attr"` +} + +type Xrd struct { + Service []*XrdsIdentifier `xml:"Service"` +} + +type XrdsDocument struct { + XMLName xml.Name `xml:"XRDS"` + Xrd *Xrd `xml:"XRD"` +} + +func parseXrds(input []byte) (opEndpoint, opLocalID string, err error) { + xrdsDoc := &XrdsDocument{} + err = xml.Unmarshal(input, xrdsDoc) + if err != nil { + return + } + + if xrdsDoc.Xrd == nil { + return "", "", errors.New("XRDS document missing XRD tag") + } + + // 7.3.2.2. Extracting Authentication Data + // Once the Relying Party has obtained an XRDS document, it + // MUST first search the document (following the rules + // described in [XRI_Resolution_2.0]) for an OP Identifier + // Element. If none is found, the RP will search for a Claimed + // Identifier Element. + for _, service := range xrdsDoc.Xrd.Service { + // 7.3.2.1.1. OP Identifier Element + // An OP Identifier Element is an element with the + // following information: + // An tag whose text content is + // "http://specs.openid.net/auth/2.0/server". + // An tag whose text content is the OP Endpoint URL + if service.hasType("http://specs.openid.net/auth/2.0/server") { + opEndpoint = strings.TrimSpace(service.URI) + return + } + } + for _, service := range xrdsDoc.Xrd.Service { + // 7.3.2.1.2. Claimed Identifier Element + // A Claimed Identifier Element is an element + // with the following information: + // An tag whose text content is + // "http://specs.openid.net/auth/2.0/signon". + // An tag whose text content is the OP Endpoint + // URL. + // An tag (optional) whose text content is the + // OP-Local Identifier. + if service.hasType("http://specs.openid.net/auth/2.0/signon") { + opEndpoint = strings.TrimSpace(service.URI) + opLocalID = strings.TrimSpace(service.LocalID) + return + } + } + return "", "", errors.New("Could not find a compatible service") +} + +func (xrdsi *XrdsIdentifier) hasType(tpe string) bool { + for _, t := range xrdsi.Type { + if t == tpe { + return true + } + } + return false +} diff --git a/vendor/github.com/yohcop/openid-go/yadis_discovery.go b/vendor/github.com/yohcop/openid-go/yadis_discovery.go new file mode 100644 index 000000000..1c2b690ff --- /dev/null +++ b/vendor/github.com/yohcop/openid-go/yadis_discovery.go @@ -0,0 +1,119 @@ +package openid + +import ( + "errors" + "io" + "io/ioutil" + "strings" + + "golang.org/x/net/html" +) + +var yadisHeaders = map[string]string{ + "Accept": "application/xrds+xml"} + +func yadisDiscovery(id string, getter httpGetter) (opEndpoint string, opLocalID string, err error) { + // Section 6.2.4 of Yadis 1.0 specifications. + // The Yadis Protocol is initiated by the Relying Party Agent + // with an initial HTTP request using the Yadis URL. + + // This request MUST be either a GET or a HEAD request. + + // A GET or HEAD request MAY include an HTTP Accept + // request-header (HTTP 14.1) specifying MIME media type, + // application/xrds+xml. + resp, err := getter.Get(id, yadisHeaders) + if err != nil { + return "", "", err + } + + defer resp.Body.Close() + + // Section 6.2.5 from Yadis 1.0 spec: Response + + contentType := resp.Header.Get("Content-Type") + + // The response MUST be one of: + // (see 6.2.6 for precedence) + if l := resp.Header.Get("X-XRDS-Location"); l != "" { + // 2. HTTP response-headers that include an X-XRDS-Location + // response-header, together with a document + return getYadisResourceDescriptor(l, getter) + } else if strings.Contains(contentType, "text/html") { + // 1. An HTML document with a element that includes a + // element with http-equiv attribute, X-XRDS-Location, + + metaContent, err := findMetaXrdsLocation(resp.Body) + if err == nil { + return getYadisResourceDescriptor(metaContent, getter) + } + return "", "", err + } else if strings.Contains(contentType, "application/xrds+xml") { + // 4. A document of MIME media type, application/xrds+xml. + body, err := ioutil.ReadAll(resp.Body) + if err == nil { + return parseXrds(body) + } + return "", "", err + } + // 3. HTTP response-headers only, which MAY include an + // X-XRDS-Location response-header, a content-type + // response-header specifying MIME media type, + // application/xrds+xml, or both. + // (this is handled by one of the 2 previous if statements) + return "", "", errors.New("No expected header, or content type") +} + +// Similar as above, but we expect an absolute Yadis document URL. +func getYadisResourceDescriptor(id string, getter httpGetter) (opEndpoint string, opLocalID string, err error) { + resp, err := getter.Get(id, yadisHeaders) + if err != nil { + return "", "", err + } + defer resp.Body.Close() + // 4. A document of MIME media type, application/xrds+xml. + body, err := ioutil.ReadAll(resp.Body) + if err == nil { + return parseXrds(body) + } + return "", "", err +} + +// Search for +// +// +func findMetaXrdsLocation(input io.Reader) (location string, err error) { + tokenizer := html.NewTokenizer(input) + inHead := false + for { + tt := tokenizer.Next() + switch tt { + case html.ErrorToken: + return "", tokenizer.Err() + case html.StartTagToken, html.EndTagToken: + tk := tokenizer.Token() + if tk.Data == "head" { + if tt == html.StartTagToken { + inHead = true + } else { + return "", errors.New("Meta X-XRDS-Location not found") + } + } else if inHead && tk.Data == "meta" { + ok := false + content := "" + for _, attr := range tk.Attr { + if attr.Key == "http-equiv" && + strings.ToLower(attr.Val) == "x-xrds-location" { + ok = true + } else if attr.Key == "content" { + content = attr.Val + } + } + if ok && len(content) > 0 { + return content, nil + } + } + } + } + return "", errors.New("Meta X-XRDS-Location not found") +} diff --git a/vendor/vendor.json b/vendor/vendor.json index 0fd700adc..91d889c6f 100644 --- a/vendor/vendor.json +++ b/vendor/vendor.json @@ -1162,6 +1162,12 @@ "path": "golang.org/x/crypto/cast5", "revision": "b8a2a83acfe6e6770b75de42d5ff4c67596675c0", "revisionTime": "2017-01-13T19:21:00Z" + }, + { + "checksumSHA1": "pkrINpw0HkmO+18SdtSjje9MB9g=", + "path": "github.com/yohcop/openid-go", + "revision": "2c050d2dae5345c417db301f11fda6fbf5ad0f0a", + "revisionTime": "2016-09-14T08:04:27Z" }, { "checksumSHA1": "dwOedwBJ1EIK9+S3t108Bx054Y8=",