diff options
Diffstat (limited to 'routers/web/user')
22 files changed, 6466 insertions, 0 deletions
diff --git a/routers/web/user/auth.go b/routers/web/user/auth.go new file mode 100644 index 0000000000..827b7cdef0 --- /dev/null +++ b/routers/web/user/auth.go @@ -0,0 +1,1769 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// Copyright 2018 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 ( + "errors" + "fmt" + "io" + "io/ioutil" + "net/http" + "strings" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/auth/oauth2" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/eventsource" + "code.gitea.io/gitea/modules/hcaptcha" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/password" + "code.gitea.io/gitea/modules/recaptcha" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/timeutil" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/modules/web/middleware" + "code.gitea.io/gitea/routers/utils" + "code.gitea.io/gitea/services/externalaccount" + "code.gitea.io/gitea/services/forms" + "code.gitea.io/gitea/services/mailer" + + "github.com/markbates/goth" + "github.com/tstranex/u2f" +) + +const ( + // tplMustChangePassword template for updating a user's password + tplMustChangePassword = "user/auth/change_passwd" + // tplSignIn template for sign in page + tplSignIn base.TplName = "user/auth/signin" + // tplSignUp template path for sign up page + tplSignUp base.TplName = "user/auth/signup" + // TplActivate template path for activate user + TplActivate base.TplName = "user/auth/activate" + tplForgotPassword base.TplName = "user/auth/forgot_passwd" + tplResetPassword base.TplName = "user/auth/reset_passwd" + tplTwofa base.TplName = "user/auth/twofa" + tplTwofaScratch base.TplName = "user/auth/twofa_scratch" + tplLinkAccount base.TplName = "user/auth/link_account" + tplU2F base.TplName = "user/auth/u2f" +) + +// AutoSignIn reads cookie and try to auto-login. +func AutoSignIn(ctx *context.Context) (bool, error) { + if !models.HasEngine { + return false, nil + } + + uname := ctx.GetCookie(setting.CookieUserName) + if len(uname) == 0 { + return false, nil + } + + isSucceed := false + defer func() { + if !isSucceed { + log.Trace("auto-login cookie cleared: %s", uname) + ctx.DeleteCookie(setting.CookieUserName) + ctx.DeleteCookie(setting.CookieRememberName) + } + }() + + u, err := models.GetUserByName(uname) + if err != nil { + if !models.IsErrUserNotExist(err) { + return false, fmt.Errorf("GetUserByName: %v", err) + } + return false, nil + } + + if val, ok := ctx.GetSuperSecureCookie( + base.EncodeMD5(u.Rands+u.Passwd), setting.CookieRememberName); !ok || val != u.Name { + return false, nil + } + + isSucceed = true + + // Set session IDs + if err := ctx.Session.Set("uid", u.ID); err != nil { + return false, err + } + if err := ctx.Session.Set("uname", u.Name); err != nil { + return false, err + } + if err := ctx.Session.Release(); err != nil { + return false, err + } + + middleware.DeleteCSRFCookie(ctx.Resp) + return true, nil +} + +func checkAutoLogin(ctx *context.Context) bool { + // Check auto-login. + isSucceed, err := AutoSignIn(ctx) + if err != nil { + ctx.ServerError("AutoSignIn", err) + return true + } + + redirectTo := ctx.Query("redirect_to") + if len(redirectTo) > 0 { + middleware.SetRedirectToCookie(ctx.Resp, redirectTo) + } else { + redirectTo = ctx.GetCookie("redirect_to") + } + + if isSucceed { + middleware.DeleteRedirectToCookie(ctx.Resp) + ctx.RedirectToFirst(redirectTo, setting.AppSubURL+string(setting.LandingPageURL)) + return true + } + + return false +} + +// SignIn render sign in page +func SignIn(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("sign_in") + + // Check auto-login. + if checkAutoLogin(ctx) { + return + } + + orderedOAuth2Names, oauth2Providers, err := models.GetActiveOAuth2Providers() + if err != nil { + ctx.ServerError("UserSignIn", err) + return + } + ctx.Data["OrderedOAuth2Names"] = orderedOAuth2Names + ctx.Data["OAuth2Providers"] = oauth2Providers + ctx.Data["Title"] = ctx.Tr("sign_in") + ctx.Data["SignInLink"] = setting.AppSubURL + "/user/login" + ctx.Data["PageIsSignIn"] = true + ctx.Data["PageIsLogin"] = true + ctx.Data["EnableSSPI"] = models.IsSSPIEnabled() + + ctx.HTML(http.StatusOK, tplSignIn) +} + +// SignInPost response for sign in request +func SignInPost(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("sign_in") + + orderedOAuth2Names, oauth2Providers, err := models.GetActiveOAuth2Providers() + if err != nil { + ctx.ServerError("UserSignIn", err) + return + } + ctx.Data["OrderedOAuth2Names"] = orderedOAuth2Names + ctx.Data["OAuth2Providers"] = oauth2Providers + ctx.Data["Title"] = ctx.Tr("sign_in") + ctx.Data["SignInLink"] = setting.AppSubURL + "/user/login" + ctx.Data["PageIsSignIn"] = true + ctx.Data["PageIsLogin"] = true + ctx.Data["EnableSSPI"] = models.IsSSPIEnabled() + + if ctx.HasError() { + ctx.HTML(http.StatusOK, tplSignIn) + return + } + + form := web.GetForm(ctx).(*forms.SignInForm) + u, err := models.UserSignIn(form.UserName, form.Password) + if err != nil { + if models.IsErrUserNotExist(err) { + ctx.RenderWithErr(ctx.Tr("form.username_password_incorrect"), tplSignIn, &form) + log.Info("Failed authentication attempt for %s from %s: %v", form.UserName, ctx.RemoteAddr(), err) + } else if models.IsErrEmailAlreadyUsed(err) { + ctx.RenderWithErr(ctx.Tr("form.email_been_used"), tplSignIn, &form) + log.Info("Failed authentication attempt for %s from %s: %v", form.UserName, ctx.RemoteAddr(), err) + } else if models.IsErrUserProhibitLogin(err) { + log.Info("Failed authentication attempt for %s from %s: %v", form.UserName, ctx.RemoteAddr(), err) + ctx.Data["Title"] = ctx.Tr("auth.prohibit_login") + ctx.HTML(http.StatusOK, "user/auth/prohibit_login") + } else if models.IsErrUserInactive(err) { + if setting.Service.RegisterEmailConfirm { + ctx.Data["Title"] = ctx.Tr("auth.active_your_account") + ctx.HTML(http.StatusOK, TplActivate) + } else { + log.Info("Failed authentication attempt for %s from %s: %v", form.UserName, ctx.RemoteAddr(), err) + ctx.Data["Title"] = ctx.Tr("auth.prohibit_login") + ctx.HTML(http.StatusOK, "user/auth/prohibit_login") + } + } else { + ctx.ServerError("UserSignIn", err) + } + return + } + // If this user is enrolled in 2FA, we can't sign the user in just yet. + // Instead, redirect them to the 2FA authentication page. + _, err = models.GetTwoFactorByUID(u.ID) + if err != nil { + if models.IsErrTwoFactorNotEnrolled(err) { + handleSignIn(ctx, u, form.Remember) + } else { + ctx.ServerError("UserSignIn", err) + } + return + } + + // User needs to use 2FA, save data and redirect to 2FA page. + if err := ctx.Session.Set("twofaUid", u.ID); err != nil { + ctx.ServerError("UserSignIn: Unable to set twofaUid in session", err) + return + } + if err := ctx.Session.Set("twofaRemember", form.Remember); err != nil { + ctx.ServerError("UserSignIn: Unable to set twofaRemember in session", err) + return + } + if err := ctx.Session.Release(); err != nil { + ctx.ServerError("UserSignIn: Unable to save session", err) + return + } + + regs, err := models.GetU2FRegistrationsByUID(u.ID) + if err == nil && len(regs) > 0 { + ctx.Redirect(setting.AppSubURL + "/user/u2f") + return + } + + ctx.Redirect(setting.AppSubURL + "/user/two_factor") +} + +// TwoFactor shows the user a two-factor authentication page. +func TwoFactor(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("twofa") + + // Check auto-login. + if checkAutoLogin(ctx) { + return + } + + // Ensure user is in a 2FA session. + if ctx.Session.Get("twofaUid") == nil { + ctx.ServerError("UserSignIn", errors.New("not in 2FA session")) + return + } + + ctx.HTML(http.StatusOK, tplTwofa) +} + +// TwoFactorPost validates a user's two-factor authentication token. +func TwoFactorPost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.TwoFactorAuthForm) + ctx.Data["Title"] = ctx.Tr("twofa") + + // Ensure user is in a 2FA session. + idSess := ctx.Session.Get("twofaUid") + if idSess == nil { + ctx.ServerError("UserSignIn", errors.New("not in 2FA session")) + return + } + + id := idSess.(int64) + twofa, err := models.GetTwoFactorByUID(id) + if err != nil { + ctx.ServerError("UserSignIn", err) + return + } + + // Validate the passcode with the stored TOTP secret. + ok, err := twofa.ValidateTOTP(form.Passcode) + if err != nil { + ctx.ServerError("UserSignIn", err) + return + } + + if ok && twofa.LastUsedPasscode != form.Passcode { + remember := ctx.Session.Get("twofaRemember").(bool) + u, err := models.GetUserByID(id) + if err != nil { + ctx.ServerError("UserSignIn", err) + return + } + + if ctx.Session.Get("linkAccount") != nil { + gothUser := ctx.Session.Get("linkAccountGothUser") + if gothUser == nil { + ctx.ServerError("UserSignIn", errors.New("not in LinkAccount session")) + return + } + + err = externalaccount.LinkAccountToUser(u, gothUser.(goth.User)) + if err != nil { + ctx.ServerError("UserSignIn", err) + return + } + } + + twofa.LastUsedPasscode = form.Passcode + if err = models.UpdateTwoFactor(twofa); err != nil { + ctx.ServerError("UserSignIn", err) + return + } + + handleSignIn(ctx, u, remember) + return + } + + ctx.RenderWithErr(ctx.Tr("auth.twofa_passcode_incorrect"), tplTwofa, forms.TwoFactorAuthForm{}) +} + +// TwoFactorScratch shows the scratch code form for two-factor authentication. +func TwoFactorScratch(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("twofa_scratch") + + // Check auto-login. + if checkAutoLogin(ctx) { + return + } + + // Ensure user is in a 2FA session. + if ctx.Session.Get("twofaUid") == nil { + ctx.ServerError("UserSignIn", errors.New("not in 2FA session")) + return + } + + ctx.HTML(http.StatusOK, tplTwofaScratch) +} + +// TwoFactorScratchPost validates and invalidates a user's two-factor scratch token. +func TwoFactorScratchPost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.TwoFactorScratchAuthForm) + ctx.Data["Title"] = ctx.Tr("twofa_scratch") + + // Ensure user is in a 2FA session. + idSess := ctx.Session.Get("twofaUid") + if idSess == nil { + ctx.ServerError("UserSignIn", errors.New("not in 2FA session")) + return + } + + id := idSess.(int64) + twofa, err := models.GetTwoFactorByUID(id) + if err != nil { + ctx.ServerError("UserSignIn", err) + return + } + + // Validate the passcode with the stored TOTP secret. + if twofa.VerifyScratchToken(form.Token) { + // Invalidate the scratch token. + _, err = twofa.GenerateScratchToken() + if err != nil { + ctx.ServerError("UserSignIn", err) + return + } + if err = models.UpdateTwoFactor(twofa); err != nil { + ctx.ServerError("UserSignIn", err) + return + } + + remember := ctx.Session.Get("twofaRemember").(bool) + u, err := models.GetUserByID(id) + if err != nil { + ctx.ServerError("UserSignIn", err) + return + } + + handleSignInFull(ctx, u, remember, false) + ctx.Flash.Info(ctx.Tr("auth.twofa_scratch_used")) + ctx.Redirect(setting.AppSubURL + "/user/settings/security") + return + } + + ctx.RenderWithErr(ctx.Tr("auth.twofa_scratch_token_incorrect"), tplTwofaScratch, forms.TwoFactorScratchAuthForm{}) +} + +// U2F shows the U2F login page +func U2F(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("twofa") + ctx.Data["RequireU2F"] = true + // Check auto-login. + if checkAutoLogin(ctx) { + return + } + + // Ensure user is in a 2FA session. + if ctx.Session.Get("twofaUid") == nil { + ctx.ServerError("UserSignIn", errors.New("not in U2F session")) + return + } + + ctx.HTML(http.StatusOK, tplU2F) +} + +// U2FChallenge submits a sign challenge to the browser +func U2FChallenge(ctx *context.Context) { + // Ensure user is in a U2F session. + idSess := ctx.Session.Get("twofaUid") + if idSess == nil { + ctx.ServerError("UserSignIn", errors.New("not in U2F session")) + return + } + id := idSess.(int64) + regs, err := models.GetU2FRegistrationsByUID(id) + if err != nil { + ctx.ServerError("UserSignIn", err) + return + } + if len(regs) == 0 { + ctx.ServerError("UserSignIn", errors.New("no device registered")) + return + } + challenge, err := u2f.NewChallenge(setting.U2F.AppID, setting.U2F.TrustedFacets) + if err != nil { + ctx.ServerError("u2f.NewChallenge", err) + return + } + if err := ctx.Session.Set("u2fChallenge", challenge); err != nil { + ctx.ServerError("UserSignIn: unable to set u2fChallenge in session", err) + return + } + if err := ctx.Session.Release(); err != nil { + ctx.ServerError("UserSignIn: unable to store session", err) + } + + ctx.JSON(http.StatusOK, challenge.SignRequest(regs.ToRegistrations())) +} + +// U2FSign authenticates the user by signResp +func U2FSign(ctx *context.Context) { + signResp := web.GetForm(ctx).(*u2f.SignResponse) + challSess := ctx.Session.Get("u2fChallenge") + idSess := ctx.Session.Get("twofaUid") + if challSess == nil || idSess == nil { + ctx.ServerError("UserSignIn", errors.New("not in U2F session")) + return + } + challenge := challSess.(*u2f.Challenge) + id := idSess.(int64) + regs, err := models.GetU2FRegistrationsByUID(id) + if err != nil { + ctx.ServerError("UserSignIn", err) + return + } + for _, reg := range regs { + r, err := reg.Parse() + if err != nil { + log.Fatal("parsing u2f registration: %v", err) + continue + } + newCounter, authErr := r.Authenticate(*signResp, *challenge, reg.Counter) + if authErr == nil { + reg.Counter = newCounter + user, err := models.GetUserByID(id) + if err != nil { + ctx.ServerError("UserSignIn", err) + return + } + remember := ctx.Session.Get("twofaRemember").(bool) + if err := reg.UpdateCounter(); err != nil { + ctx.ServerError("UserSignIn", err) + return + } + + if ctx.Session.Get("linkAccount") != nil { + gothUser := ctx.Session.Get("linkAccountGothUser") + if gothUser == nil { + ctx.ServerError("UserSignIn", errors.New("not in LinkAccount session")) + return + } + + err = externalaccount.LinkAccountToUser(user, gothUser.(goth.User)) + if err != nil { + ctx.ServerError("UserSignIn", err) + return + } + } + redirect := handleSignInFull(ctx, user, remember, false) + if redirect == "" { + redirect = setting.AppSubURL + "/" + } + ctx.PlainText(200, []byte(redirect)) + return + } + } + ctx.Error(http.StatusUnauthorized) +} + +// This handles the final part of the sign-in process of the user. +func handleSignIn(ctx *context.Context, u *models.User, remember bool) { + handleSignInFull(ctx, u, remember, true) +} + +func handleSignInFull(ctx *context.Context, u *models.User, remember bool, obeyRedirect bool) string { + if remember { + days := 86400 * setting.LogInRememberDays + ctx.SetCookie(setting.CookieUserName, u.Name, days) + ctx.SetSuperSecureCookie(base.EncodeMD5(u.Rands+u.Passwd), + setting.CookieRememberName, u.Name, days) + } + + _ = 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.Delete("u2fChallenge") + _ = ctx.Session.Delete("linkAccount") + if err := ctx.Session.Set("uid", u.ID); err != nil { + log.Error("Error setting uid %d in session: %v", u.ID, err) + } + if err := ctx.Session.Set("uname", u.Name); err != nil { + log.Error("Error setting uname %s session: %v", u.Name, err) + } + if err := ctx.Session.Release(); err != nil { + log.Error("Unable to store session: %v", err) + } + + // Language setting of the user overwrites the one previously set + // If the user does not have a locale set, we save the current one. + if len(u.Language) == 0 { + u.Language = ctx.Locale.Language() + if err := models.UpdateUserCols(u, "language"); err != nil { + log.Error(fmt.Sprintf("Error updating user language [user: %d, locale: %s]", u.ID, u.Language)) + return setting.AppSubURL + "/" + } + } + + middleware.SetLocaleCookie(ctx.Resp, u.Language, 0) + + // Clear whatever CSRF has right now, force to generate a new one + middleware.DeleteCSRFCookie(ctx.Resp) + + // Register last login + u.SetLastLogin() + if err := models.UpdateUserCols(u, "last_login_unix"); err != nil { + ctx.ServerError("UpdateUserCols", err) + return setting.AppSubURL + "/" + } + + if redirectTo := ctx.GetCookie("redirect_to"); len(redirectTo) > 0 && !utils.IsExternalURL(redirectTo) { + middleware.DeleteRedirectToCookie(ctx.Resp) + if obeyRedirect { + ctx.RedirectToFirst(redirectTo) + } + return redirectTo + } + + if obeyRedirect { + ctx.Redirect(setting.AppSubURL + "/") + } + return setting.AppSubURL + "/" +} + +// SignInOAuth handles the OAuth2 login buttons +func SignInOAuth(ctx *context.Context) { + provider := ctx.Params(":provider") + + loginSource, err := models.GetActiveOAuth2LoginSourceByName(provider) + if err != nil { + ctx.ServerError("SignIn", err) + return + } + + // try to do a direct callback flow, so we don't authenticate the user again but use the valid accesstoken to get the user + user, gothUser, err := oAuth2UserLoginCallback(loginSource, ctx.Req, ctx.Resp) + if err == nil && user != nil { + // we got the user without going through the whole OAuth2 authentication flow again + handleOAuth2SignIn(ctx, user, gothUser) + return + } + + if err = oauth2.Auth(loginSource.Name, ctx.Req, ctx.Resp); err != nil { + if strings.Contains(err.Error(), "no provider for ") { + if err = models.ResetOAuth2(); err != nil { + ctx.ServerError("SignIn", err) + return + } + if err = oauth2.Auth(loginSource.Name, ctx.Req, ctx.Resp); err != nil { + ctx.ServerError("SignIn", err) + } + return + } + ctx.ServerError("SignIn", err) + } + // redirect is done in oauth2.Auth +} + +// SignInOAuthCallback handles the callback from the given provider +func SignInOAuthCallback(ctx *context.Context) { + provider := ctx.Params(":provider") + + // first look if the provider is still active + loginSource, err := models.GetActiveOAuth2LoginSourceByName(provider) + if err != nil { + ctx.ServerError("SignIn", err) + return + } + + if loginSource == nil { + ctx.ServerError("SignIn", errors.New("No valid provider found, check configured callback url in provider")) + return + } + + u, gothUser, err := oAuth2UserLoginCallback(loginSource, ctx.Req, ctx.Resp) + + if err != nil { + ctx.ServerError("UserSignIn", err) + return + } + + if u == nil { + if !(setting.Service.DisableRegistration || setting.Service.AllowOnlyInternalRegistration) && setting.OAuth2Client.EnableAutoRegistration { + // create new user with details from oauth2 provider + var missingFields []string + if gothUser.UserID == "" { + missingFields = append(missingFields, "sub") + } + if gothUser.Email == "" { + missingFields = append(missingFields, "email") + } + if setting.OAuth2Client.Username == setting.OAuth2UsernameNickname && gothUser.NickName == "" { + missingFields = append(missingFields, "nickname") + } + if len(missingFields) > 0 { + log.Error("OAuth2 Provider %s returned empty or missing fields: %s", loginSource.Name, missingFields) + if loginSource.IsOAuth2() && loginSource.OAuth2().Provider == "openidConnect" { + log.Error("You may need to change the 'OPENID_CONNECT_SCOPES' setting to request all required fields") + } + err = fmt.Errorf("OAuth2 Provider %s returned empty or missing fields: %s", loginSource.Name, missingFields) + ctx.ServerError("CreateUser", err) + return + } + u = &models.User{ + Name: getUserName(&gothUser), + FullName: gothUser.Name, + Email: gothUser.Email, + IsActive: !setting.OAuth2Client.RegisterEmailConfirm, + LoginType: models.LoginOAuth2, + LoginSource: loginSource.ID, + LoginName: gothUser.UserID, + } + + if !createAndHandleCreatedUser(ctx, base.TplName(""), nil, u, &gothUser, setting.OAuth2Client.AccountLinking != setting.OAuth2AccountLinkingDisabled) { + // error already handled + return + } + } else { + // no existing user is found, request attach or new account + showLinkingLogin(ctx, gothUser) + return + } + } + + handleOAuth2SignIn(ctx, u, gothUser) +} + +func getUserName(gothUser *goth.User) string { + switch setting.OAuth2Client.Username { + case setting.OAuth2UsernameEmail: + return strings.Split(gothUser.Email, "@")[0] + case setting.OAuth2UsernameNickname: + return gothUser.NickName + default: // OAuth2UsernameUserid + return gothUser.UserID + } +} + +func showLinkingLogin(ctx *context.Context, gothUser goth.User) { + if err := ctx.Session.Set("linkAccountGothUser", gothUser); err != nil { + log.Error("Error setting linkAccountGothUser in session: %v", err) + } + if err := ctx.Session.Release(); err != nil { + log.Error("Error storing session: %v", err) + } + ctx.Redirect(setting.AppSubURL + "/user/link_account") +} + +func updateAvatarIfNeed(url string, u *models.User) { + if setting.OAuth2Client.UpdateAvatar && len(url) > 0 { + resp, err := http.Get(url) + if err == nil { + defer func() { + _ = resp.Body.Close() + }() + } + // ignore any error + if err == nil && resp.StatusCode == http.StatusOK { + data, err := ioutil.ReadAll(io.LimitReader(resp.Body, setting.Avatar.MaxFileSize+1)) + if err == nil && int64(len(data)) <= setting.Avatar.MaxFileSize { + _ = u.UploadAvatar(data) + } + } + } +} + +func handleOAuth2SignIn(ctx *context.Context, u *models.User, gothUser goth.User) { + updateAvatarIfNeed(gothUser.AvatarURL, u) + + // If this user is enrolled in 2FA, we can't sign the user in just yet. + // Instead, redirect them to the 2FA authentication page. + _, err := models.GetTwoFactorByUID(u.ID) + if err != nil { + if !models.IsErrTwoFactorNotEnrolled(err) { + ctx.ServerError("UserSignIn", err) + return + } + + if err := ctx.Session.Set("uid", u.ID); err != nil { + log.Error("Error setting uid in session: %v", err) + } + if err := ctx.Session.Set("uname", u.Name); err != nil { + log.Error("Error setting uname in session: %v", err) + } + if err := ctx.Session.Release(); err != nil { + log.Error("Error storing session: %v", err) + } + + // Clear whatever CSRF has right now, force to generate a new one + middleware.DeleteCSRFCookie(ctx.Resp) + + // Register last login + u.SetLastLogin() + if err := models.UpdateUserCols(u, "last_login_unix"); err != nil { + ctx.ServerError("UpdateUserCols", err) + return + } + + // update external user information + if err := models.UpdateExternalUser(u, gothUser); err != nil { + log.Error("UpdateExternalUser failed: %v", err) + } + + if redirectTo := ctx.GetCookie("redirect_to"); len(redirectTo) > 0 { + middleware.DeleteRedirectToCookie(ctx.Resp) + ctx.RedirectToFirst(redirectTo) + return + } + + ctx.Redirect(setting.AppSubURL + "/") + return + } + + // User needs to use 2FA, save data and redirect to 2FA page. + if err := ctx.Session.Set("twofaUid", u.ID); err != nil { + log.Error("Error setting twofaUid in session: %v", err) + } + if err := ctx.Session.Set("twofaRemember", false); err != nil { + log.Error("Error setting twofaRemember in session: %v", err) + } + if err := ctx.Session.Release(); err != nil { + log.Error("Error storing session: %v", err) + } + + // If U2F is enrolled -> Redirect to U2F instead + regs, err := models.GetU2FRegistrationsByUID(u.ID) + if err == nil && len(regs) > 0 { + ctx.Redirect(setting.AppSubURL + "/user/u2f") + return + } + + ctx.Redirect(setting.AppSubURL + "/user/two_factor") +} + +// OAuth2UserLoginCallback attempts to handle the callback from the OAuth2 provider and if successful +// login the user +func oAuth2UserLoginCallback(loginSource *models.LoginSource, request *http.Request, response http.ResponseWriter) (*models.User, goth.User, error) { + gothUser, err := oauth2.ProviderCallback(loginSource.Name, request, response) + + if err != nil { + if err.Error() == "securecookie: the value is too long" { + log.Error("OAuth2 Provider %s returned too long a token. Current max: %d. Either increase the [OAuth2] MAX_TOKEN_LENGTH or reduce the information returned from the OAuth2 provider", loginSource.Name, setting.OAuth2.MaxTokenLength) + err = fmt.Errorf("OAuth2 Provider %s returned too long a token. Current max: %d. Either increase the [OAuth2] MAX_TOKEN_LENGTH or reduce the information returned from the OAuth2 provider", loginSource.Name, setting.OAuth2.MaxTokenLength) + } + return nil, goth.User{}, err + } + + user := &models.User{ + LoginName: gothUser.UserID, + LoginType: models.LoginOAuth2, + LoginSource: loginSource.ID, + } + + hasUser, err := models.GetUser(user) + if err != nil { + return nil, goth.User{}, err + } + + if hasUser { + return user, gothUser, nil + } + + // search in external linked users + externalLoginUser := &models.ExternalLoginUser{ + ExternalID: gothUser.UserID, + LoginSourceID: loginSource.ID, + } + hasUser, err = models.GetExternalLogin(externalLoginUser) + if err != nil { + return nil, goth.User{}, err + } + if hasUser { + user, err = models.GetUserByID(externalLoginUser.UserID) + return user, gothUser, err + } + + // no user found to login + return nil, gothUser, nil + +} + +// LinkAccount shows the page where the user can decide to login or create a new account +func LinkAccount(ctx *context.Context) { + ctx.Data["DisablePassword"] = !setting.Service.RequireExternalRegistrationPassword || setting.Service.AllowOnlyExternalRegistration + ctx.Data["Title"] = ctx.Tr("link_account") + ctx.Data["LinkAccountMode"] = true + ctx.Data["EnableCaptcha"] = setting.Service.EnableCaptcha && setting.Service.RequireExternalRegistrationCaptcha + ctx.Data["Captcha"] = context.GetImageCaptcha() + ctx.Data["CaptchaType"] = setting.Service.CaptchaType + ctx.Data["RecaptchaURL"] = setting.Service.RecaptchaURL + ctx.Data["RecaptchaSitekey"] = setting.Service.RecaptchaSitekey + ctx.Data["HcaptchaSitekey"] = setting.Service.HcaptchaSitekey + ctx.Data["DisableRegistration"] = setting.Service.DisableRegistration + ctx.Data["AllowOnlyInternalRegistration"] = setting.Service.AllowOnlyInternalRegistration + ctx.Data["ShowRegistrationButton"] = false + + // use this to set the right link into the signIn and signUp templates in the link_account template + ctx.Data["SignInLink"] = setting.AppSubURL + "/user/link_account_signin" + ctx.Data["SignUpLink"] = setting.AppSubURL + "/user/link_account_signup" + + gothUser := ctx.Session.Get("linkAccountGothUser") + if gothUser == nil { + ctx.ServerError("UserSignIn", errors.New("not in LinkAccount session")) + return + } + + gu, _ := gothUser.(goth.User) + uname := getUserName(&gu) + email := gu.Email + ctx.Data["user_name"] = uname + ctx.Data["email"] = email + + if len(email) != 0 { + u, err := models.GetUserByEmail(email) + if err != nil && !models.IsErrUserNotExist(err) { + ctx.ServerError("UserSignIn", err) + return + } + if u != nil { + ctx.Data["user_exists"] = true + } + } else if len(uname) != 0 { + u, err := models.GetUserByName(uname) + if err != nil && !models.IsErrUserNotExist(err) { + ctx.ServerError("UserSignIn", err) + return + } + if u != nil { + ctx.Data["user_exists"] = true + } + } + + ctx.HTML(http.StatusOK, tplLinkAccount) +} + +// LinkAccountPostSignIn handle the coupling of external account with another account using signIn +func LinkAccountPostSignIn(ctx *context.Context) { + signInForm := web.GetForm(ctx).(*forms.SignInForm) + ctx.Data["DisablePassword"] = !setting.Service.RequireExternalRegistrationPassword || setting.Service.AllowOnlyExternalRegistration + ctx.Data["Title"] = ctx.Tr("link_account") + ctx.Data["LinkAccountMode"] = true + ctx.Data["LinkAccountModeSignIn"] = true + ctx.Data["EnableCaptcha"] = setting.Service.EnableCaptcha && setting.Service.RequireExternalRegistrationCaptcha + ctx.Data["RecaptchaURL"] = setting.Service.RecaptchaURL + ctx.Data["Captcha"] = context.GetImageCaptcha() + ctx.Data["CaptchaType"] = setting.Service.CaptchaType + ctx.Data["RecaptchaSitekey"] = setting.Service.RecaptchaSitekey + ctx.Data["HcaptchaSitekey"] = setting.Service.HcaptchaSitekey + ctx.Data["DisableRegistration"] = setting.Service.DisableRegistration + ctx.Data["ShowRegistrationButton"] = false + + // use this to set the right link into the signIn and signUp templates in the link_account template + ctx.Data["SignInLink"] = setting.AppSubURL + "/user/link_account_signin" + ctx.Data["SignUpLink"] = setting.AppSubURL + "/user/link_account_signup" + + gothUser := ctx.Session.Get("linkAccountGothUser") + if gothUser == nil { + ctx.ServerError("UserSignIn", errors.New("not in LinkAccount session")) + return + } + + if ctx.HasError() { + ctx.HTML(http.StatusOK, tplLinkAccount) + return + } + + u, err := models.UserSignIn(signInForm.UserName, signInForm.Password) + if err != nil { + if models.IsErrUserNotExist(err) { + ctx.Data["user_exists"] = true + ctx.RenderWithErr(ctx.Tr("form.username_password_incorrect"), tplLinkAccount, &signInForm) + } else { + ctx.ServerError("UserLinkAccount", err) + } + return + } + + linkAccount(ctx, u, gothUser.(goth.User), signInForm.Remember) +} + +func linkAccount(ctx *context.Context, u *models.User, gothUser goth.User, remember bool) { + updateAvatarIfNeed(gothUser.AvatarURL, u) + + // If this user is enrolled in 2FA, we can't sign the user in just yet. + // Instead, redirect them to the 2FA authentication page. + _, err := models.GetTwoFactorByUID(u.ID) + if err != nil { + if !models.IsErrTwoFactorNotEnrolled(err) { + ctx.ServerError("UserLinkAccount", err) + return + } + + err = externalaccount.LinkAccountToUser(u, gothUser) + if err != nil { + ctx.ServerError("UserLinkAccount", err) + return + } + + handleSignIn(ctx, u, remember) + return + } + + // User needs to use 2FA, save data and redirect to 2FA page. + if err := ctx.Session.Set("twofaUid", u.ID); err != nil { + log.Error("Error setting twofaUid in session: %v", err) + } + if err := ctx.Session.Set("twofaRemember", remember); err != nil { + log.Error("Error setting twofaRemember in session: %v", err) + } + if err := ctx.Session.Set("linkAccount", true); err != nil { + log.Error("Error setting linkAccount in session: %v", err) + } + if err := ctx.Session.Release(); err != nil { + log.Error("Error storing session: %v", err) + } + + // If U2F is enrolled -> Redirect to U2F instead + regs, err := models.GetU2FRegistrationsByUID(u.ID) + if err == nil && len(regs) > 0 { + ctx.Redirect(setting.AppSubURL + "/user/u2f") + return + } + + ctx.Redirect(setting.AppSubURL + "/user/two_factor") +} + +// LinkAccountPostRegister handle the creation of a new account for an external account using signUp +func LinkAccountPostRegister(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.RegisterForm) + // TODO Make insecure passwords optional for local accounts also, + // once email-based Second-Factor Auth is available + ctx.Data["DisablePassword"] = !setting.Service.RequireExternalRegistrationPassword || setting.Service.AllowOnlyExternalRegistration + ctx.Data["Title"] = ctx.Tr("link_account") + ctx.Data["LinkAccountMode"] = true + ctx.Data["LinkAccountModeRegister"] = true + ctx.Data["EnableCaptcha"] = setting.Service.EnableCaptcha && setting.Service.RequireExternalRegistrationCaptcha + ctx.Data["RecaptchaURL"] = setting.Service.RecaptchaURL + ctx.Data["Captcha"] = context.GetImageCaptcha() + ctx.Data["CaptchaType"] = setting.Service.CaptchaType + ctx.Data["RecaptchaSitekey"] = setting.Service.RecaptchaSitekey + ctx.Data["HcaptchaSitekey"] = setting.Service.HcaptchaSitekey + ctx.Data["DisableRegistration"] = setting.Service.DisableRegistration + ctx.Data["ShowRegistrationButton"] = false + + // use this to set the right link into the signIn and signUp templates in the link_account template + ctx.Data["SignInLink"] = setting.AppSubURL + "/user/link_account_signin" + ctx.Data["SignUpLink"] = setting.AppSubURL + "/user/link_account_signup" + + gothUserInterface := ctx.Session.Get("linkAccountGothUser") + if gothUserInterface == nil { + ctx.ServerError("UserSignUp", errors.New("not in LinkAccount session")) + return + } + gothUser, ok := gothUserInterface.(goth.User) + if !ok { + ctx.ServerError("UserSignUp", fmt.Errorf("session linkAccountGothUser type is %t but not goth.User", gothUserInterface)) + return + } + + if ctx.HasError() { + ctx.HTML(http.StatusOK, tplLinkAccount) + return + } + + if setting.Service.DisableRegistration || setting.Service.AllowOnlyInternalRegistration { + ctx.Error(http.StatusForbidden) + return + } + + if setting.Service.EnableCaptcha && setting.Service.RequireExternalRegistrationCaptcha { + var valid bool + var err error + switch setting.Service.CaptchaType { + case setting.ImageCaptcha: + valid = context.GetImageCaptcha().VerifyReq(ctx.Req) + case setting.ReCaptcha: + valid, err = recaptcha.Verify(ctx, form.GRecaptchaResponse) + case setting.HCaptcha: + valid, err = hcaptcha.Verify(ctx, form.HcaptchaResponse) + default: + ctx.ServerError("Unknown Captcha Type", fmt.Errorf("Unknown Captcha Type: %s", setting.Service.CaptchaType)) + return + } + if err != nil { + log.Debug("%s", err.Error()) + } + + if !valid { + ctx.Data["Err_Captcha"] = true + ctx.RenderWithErr(ctx.Tr("form.captcha_incorrect"), tplLinkAccount, &form) + return + } + } + + if !form.IsEmailDomainAllowed() { + ctx.RenderWithErr(ctx.Tr("auth.email_domain_blacklisted"), tplLinkAccount, &form) + return + } + + if setting.Service.AllowOnlyExternalRegistration || !setting.Service.RequireExternalRegistrationPassword { + // In models.User an empty password is classed as not set, so we set form.Password to empty. + // Eventually the database should be changed to indicate "Second Factor"-enabled accounts + // (accounts that do not introduce the security vulnerabilities of a password). + // If a user decides to circumvent second-factor security, and purposefully create a password, + // they can still do so using the "Recover Account" option. + form.Password = "" + } else { + if (len(strings.TrimSpace(form.Password)) > 0 || len(strings.TrimSpace(form.Retype)) > 0) && form.Password != form.Retype { + ctx.Data["Err_Password"] = true + ctx.RenderWithErr(ctx.Tr("form.password_not_match"), tplLinkAccount, &form) + return + } + if len(strings.TrimSpace(form.Password)) > 0 && len(form.Password) < setting.MinPasswordLength { + ctx.Data["Err_Password"] = true + ctx.RenderWithErr(ctx.Tr("auth.password_too_short", setting.MinPasswordLength), tplLinkAccount, &form) + return + } + } + + loginSource, err := models.GetActiveOAuth2LoginSourceByName(gothUser.Provider) + if err != nil { + ctx.ServerError("CreateUser", err) + } + + u := &models.User{ + Name: form.UserName, + Email: form.Email, + Passwd: form.Password, + IsActive: !(setting.Service.RegisterEmailConfirm || setting.Service.RegisterManualConfirm), + LoginType: models.LoginOAuth2, + LoginSource: loginSource.ID, + LoginName: gothUser.UserID, + } + + if !createAndHandleCreatedUser(ctx, tplLinkAccount, form, u, &gothUser, false) { + // error already handled + return + } + + ctx.Redirect(setting.AppSubURL + "/user/login") +} + +// HandleSignOut resets the session and sets the cookies +func HandleSignOut(ctx *context.Context) { + _ = ctx.Session.Flush() + _ = ctx.Session.Destroy(ctx.Resp, ctx.Req) + ctx.DeleteCookie(setting.CookieUserName) + ctx.DeleteCookie(setting.CookieRememberName) + middleware.DeleteCSRFCookie(ctx.Resp) + middleware.DeleteLocaleCookie(ctx.Resp) + middleware.DeleteRedirectToCookie(ctx.Resp) +} + +// SignOut sign out from login status +func SignOut(ctx *context.Context) { + if ctx.User != nil { + eventsource.GetManager().SendMessageBlocking(ctx.User.ID, &eventsource.Event{ + Name: "logout", + Data: ctx.Session.ID(), + }) + } + HandleSignOut(ctx) + ctx.Redirect(setting.AppSubURL + "/") +} + +// SignUp render the register page +func SignUp(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("sign_up") + + ctx.Data["SignUpLink"] = setting.AppSubURL + "/user/sign_up" + + ctx.Data["EnableCaptcha"] = setting.Service.EnableCaptcha + ctx.Data["RecaptchaURL"] = setting.Service.RecaptchaURL + ctx.Data["Captcha"] = context.GetImageCaptcha() + ctx.Data["CaptchaType"] = setting.Service.CaptchaType + ctx.Data["RecaptchaSitekey"] = setting.Service.RecaptchaSitekey + ctx.Data["HcaptchaSitekey"] = setting.Service.HcaptchaSitekey + ctx.Data["PageIsSignUp"] = true + + //Show Disabled Registration message if DisableRegistration or AllowOnlyExternalRegistration options are true + ctx.Data["DisableRegistration"] = setting.Service.DisableRegistration || setting.Service.AllowOnlyExternalRegistration + + ctx.HTML(http.StatusOK, tplSignUp) +} + +// SignUpPost response for sign up information submission +func SignUpPost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.RegisterForm) + ctx.Data["Title"] = ctx.Tr("sign_up") + + ctx.Data["SignUpLink"] = setting.AppSubURL + "/user/sign_up" + + ctx.Data["EnableCaptcha"] = setting.Service.EnableCaptcha + ctx.Data["RecaptchaURL"] = setting.Service.RecaptchaURL + ctx.Data["Captcha"] = context.GetImageCaptcha() + ctx.Data["CaptchaType"] = setting.Service.CaptchaType + ctx.Data["RecaptchaSitekey"] = setting.Service.RecaptchaSitekey + ctx.Data["HcaptchaSitekey"] = setting.Service.HcaptchaSitekey + ctx.Data["PageIsSignUp"] = true + + //Permission denied if DisableRegistration or AllowOnlyExternalRegistration options are true + if setting.Service.DisableRegistration || setting.Service.AllowOnlyExternalRegistration { + ctx.Error(http.StatusForbidden) + return + } + + if ctx.HasError() { + ctx.HTML(http.StatusOK, tplSignUp) + return + } + + if setting.Service.EnableCaptcha { + var valid bool + var err error + switch setting.Service.CaptchaType { + case setting.ImageCaptcha: + valid = context.GetImageCaptcha().VerifyReq(ctx.Req) + case setting.ReCaptcha: + valid, err = recaptcha.Verify(ctx, form.GRecaptchaResponse) + case setting.HCaptcha: + valid, err = hcaptcha.Verify(ctx, form.HcaptchaResponse) + default: + ctx.ServerError("Unknown Captcha Type", fmt.Errorf("Unknown Captcha Type: %s", setting.Service.CaptchaType)) + return + } + if err != nil { + log.Debug("%s", err.Error()) + } + + if !valid { + ctx.Data["Err_Captcha"] = true + ctx.RenderWithErr(ctx.Tr("form.captcha_incorrect"), tplSignUp, &form) + return + } + } + + if !form.IsEmailDomainAllowed() { + ctx.RenderWithErr(ctx.Tr("auth.email_domain_blacklisted"), tplSignUp, &form) + return + } + + if form.Password != form.Retype { + ctx.Data["Err_Password"] = true + ctx.RenderWithErr(ctx.Tr("form.password_not_match"), tplSignUp, &form) + return + } + if len(form.Password) < setting.MinPasswordLength { + ctx.Data["Err_Password"] = true + ctx.RenderWithErr(ctx.Tr("auth.password_too_short", setting.MinPasswordLength), tplSignUp, &form) + return + } + if !password.IsComplexEnough(form.Password) { + ctx.Data["Err_Password"] = true + ctx.RenderWithErr(password.BuildComplexityError(ctx), tplSignUp, &form) + return + } + pwned, err := password.IsPwned(ctx, form.Password) + if pwned { + errMsg := ctx.Tr("auth.password_pwned") + if err != nil { + log.Error(err.Error()) + errMsg = ctx.Tr("auth.password_pwned_err") + } + ctx.Data["Err_Password"] = true + ctx.RenderWithErr(errMsg, tplSignUp, &form) + return + } + + u := &models.User{ + Name: form.UserName, + Email: form.Email, + Passwd: form.Password, + IsActive: !(setting.Service.RegisterEmailConfirm || setting.Service.RegisterManualConfirm), + } + + if !createAndHandleCreatedUser(ctx, tplSignUp, form, u, nil, false) { + // error already handled + return + } + + ctx.Flash.Success(ctx.Tr("auth.sign_up_successful")) + handleSignInFull(ctx, u, false, true) +} + +// createAndHandleCreatedUser calls createUserInContext and +// then handleUserCreated. +func createAndHandleCreatedUser(ctx *context.Context, tpl base.TplName, form interface{}, u *models.User, gothUser *goth.User, allowLink bool) bool { + if !createUserInContext(ctx, tpl, form, u, gothUser, allowLink) { + return false + } + return handleUserCreated(ctx, u, gothUser) +} + +// createUserInContext creates a user and handles errors within a given context. +// Optionally a template can be specified. +func createUserInContext(ctx *context.Context, tpl base.TplName, form interface{}, u *models.User, gothUser *goth.User, allowLink bool) (ok bool) { + if err := models.CreateUser(u); err != nil { + if allowLink && (models.IsErrUserAlreadyExist(err) || models.IsErrEmailAlreadyUsed(err)) { + if setting.OAuth2Client.AccountLinking == setting.OAuth2AccountLinkingAuto { + var user *models.User + user = &models.User{Name: u.Name} + hasUser, err := models.GetUser(user) + if !hasUser || err != nil { + user = &models.User{Email: u.Email} + hasUser, err = models.GetUser(user) + if !hasUser || err != nil { + ctx.ServerError("UserLinkAccount", err) + return + } + } + + // TODO: probably we should respect 'remember' user's choice... + linkAccount(ctx, user, *gothUser, true) + return // user is already created here, all redirects are handled + } else if setting.OAuth2Client.AccountLinking == setting.OAuth2AccountLinkingLogin { + showLinkingLogin(ctx, *gothUser) + return // user will be created only after linking login + } + } + + // handle error without template + if len(tpl) == 0 { + ctx.ServerError("CreateUser", err) + return + } + + // handle error with template + switch { + case models.IsErrUserAlreadyExist(err): + ctx.Data["Err_UserName"] = true + ctx.RenderWithErr(ctx.Tr("form.username_been_taken"), tpl, form) + case models.IsErrEmailAlreadyUsed(err): + ctx.Data["Err_Email"] = true + ctx.RenderWithErr(ctx.Tr("form.email_been_used"), tpl, form) + case models.IsErrEmailInvalid(err): + ctx.Data["Err_Email"] = true + ctx.RenderWithErr(ctx.Tr("form.email_invalid"), tpl, form) + case models.IsErrNameReserved(err): + ctx.Data["Err_UserName"] = true + ctx.RenderWithErr(ctx.Tr("user.form.name_reserved", err.(models.ErrNameReserved).Name), tpl, form) + case models.IsErrNamePatternNotAllowed(err): + ctx.Data["Err_UserName"] = true + ctx.RenderWithErr(ctx.Tr("user.form.name_pattern_not_allowed", err.(models.ErrNamePatternNotAllowed).Pattern), tpl, form) + case models.IsErrNameCharsNotAllowed(err): + ctx.Data["Err_UserName"] = true + ctx.RenderWithErr(ctx.Tr("user.form.name_chars_not_allowed", err.(models.ErrNameCharsNotAllowed).Name), tpl, form) + default: + ctx.ServerError("CreateUser", err) + } + return + } + log.Trace("Account created: %s", u.Name) + return true +} + +// handleUserCreated does additional steps after a new user is created. +// It auto-sets admin for the only user, updates the optional external user and +// sends a confirmation email if required. +func handleUserCreated(ctx *context.Context, u *models.User, gothUser *goth.User) (ok bool) { + // Auto-set admin for the only user. + if models.CountUsers() == 1 { + u.IsAdmin = true + u.IsActive = true + u.SetLastLogin() + if err := models.UpdateUserCols(u, "is_admin", "is_active", "last_login_unix"); err != nil { + ctx.ServerError("UpdateUser", err) + return + } + } + + // update external user information + if gothUser != nil { + if err := models.UpdateExternalUser(u, *gothUser); err != nil { + log.Error("UpdateExternalUser failed: %v", err) + } + } + + // Send confirmation email + if !u.IsActive && u.ID > 1 { + mailer.SendActivateAccountMail(ctx.Locale, u) + + ctx.Data["IsSendRegisterMail"] = true + ctx.Data["Email"] = u.Email + ctx.Data["ActiveCodeLives"] = timeutil.MinutesToFriendly(setting.Service.ActiveCodeLives, ctx.Locale.Language()) + ctx.HTML(http.StatusOK, TplActivate) + + if err := ctx.Cache.Put("MailResendLimit_"+u.LowerName, u.LowerName, 180); err != nil { + log.Error("Set cache(MailResendLimit) fail: %v", err) + } + return + } + + return true +} + +// Activate render activate user page +func Activate(ctx *context.Context) { + code := ctx.Query("code") + + if len(code) == 0 { + ctx.Data["IsActivatePage"] = true + if ctx.User == nil || ctx.User.IsActive { + ctx.NotFound("invalid user", nil) + return + } + // Resend confirmation email. + if setting.Service.RegisterEmailConfirm { + if ctx.Cache.IsExist("MailResendLimit_" + ctx.User.LowerName) { + ctx.Data["ResendLimited"] = true + } else { + ctx.Data["ActiveCodeLives"] = timeutil.MinutesToFriendly(setting.Service.ActiveCodeLives, ctx.Locale.Language()) + mailer.SendActivateAccountMail(ctx.Locale, ctx.User) + + if err := ctx.Cache.Put("MailResendLimit_"+ctx.User.LowerName, ctx.User.LowerName, 180); err != nil { + log.Error("Set cache(MailResendLimit) fail: %v", err) + } + } + } else { + ctx.Data["ServiceNotEnabled"] = true + } + ctx.HTML(http.StatusOK, TplActivate) + return + } + + user := models.VerifyUserActiveCode(code) + // if code is wrong + if user == nil { + ctx.Data["IsActivateFailed"] = true + ctx.HTML(http.StatusOK, TplActivate) + return + } + + // if account is local account, verify password + if user.LoginSource == 0 { + ctx.Data["Code"] = code + ctx.Data["NeedsPassword"] = true + ctx.HTML(http.StatusOK, TplActivate) + return + } + + handleAccountActivation(ctx, user) +} + +// ActivatePost handles account activation with password check +func ActivatePost(ctx *context.Context) { + code := ctx.Query("code") + if len(code) == 0 { + ctx.Redirect(setting.AppSubURL + "/user/activate") + return + } + + user := models.VerifyUserActiveCode(code) + // if code is wrong + if user == nil { + ctx.Data["IsActivateFailed"] = true + ctx.HTML(http.StatusOK, TplActivate) + return + } + + // if account is local account, verify password + if user.LoginSource == 0 { + password := ctx.Query("password") + if len(password) == 0 { + ctx.Data["Code"] = code + ctx.Data["NeedsPassword"] = true + ctx.HTML(http.StatusOK, TplActivate) + return + } + if !user.ValidatePassword(password) { + ctx.Data["IsActivateFailed"] = true + ctx.HTML(http.StatusOK, TplActivate) + return + } + } + + handleAccountActivation(ctx, user) +} + +func handleAccountActivation(ctx *context.Context, user *models.User) { + user.IsActive = true + var err error + if user.Rands, err = models.GetUserSalt(); err != nil { + ctx.ServerError("UpdateUser", err) + return + } + if err := models.UpdateUserCols(user, "is_active", "rands"); err != nil { + if models.IsErrUserNotExist(err) { + ctx.NotFound("UpdateUserCols", err) + } else { + ctx.ServerError("UpdateUser", err) + } + return + } + + log.Trace("User activated: %s", user.Name) + + if err := ctx.Session.Set("uid", user.ID); err != nil { + log.Error(fmt.Sprintf("Error setting uid in session: %v", err)) + } + if err := ctx.Session.Set("uname", user.Name); err != nil { + log.Error(fmt.Sprintf("Error setting uname in session: %v", err)) + } + if err := ctx.Session.Release(); err != nil { + log.Error("Error storing session: %v", err) + } + + ctx.Flash.Success(ctx.Tr("auth.account_activated")) + ctx.Redirect(setting.AppSubURL + "/") +} + +// ActivateEmail render the activate email page +func ActivateEmail(ctx *context.Context) { + code := ctx.Query("code") + emailStr := ctx.Query("email") + + // Verify code. + if email := models.VerifyActiveEmailCode(code, emailStr); email != nil { + if err := email.Activate(); err != nil { + ctx.ServerError("ActivateEmail", err) + } + + log.Trace("Email activated: %s", email.Email) + ctx.Flash.Success(ctx.Tr("settings.add_email_success")) + + if u, err := models.GetUserByID(email.UID); err != nil { + log.Warn("GetUserByID: %d", email.UID) + } else { + // Allow user to validate more emails + _ = ctx.Cache.Delete("MailResendLimit_" + u.LowerName) + } + } + + // FIXME: e-mail verification does not require the user to be logged in, + // so this could be redirecting to the login page. + // Should users be logged in automatically here? (consider 2FA requirements, etc.) + ctx.Redirect(setting.AppSubURL + "/user/settings/account") +} + +// ForgotPasswd render the forget pasword page +func ForgotPasswd(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("auth.forgot_password_title") + + if setting.MailService == nil { + ctx.Data["IsResetDisable"] = true + ctx.HTML(http.StatusOK, tplForgotPassword) + return + } + + email := ctx.Query("email") + ctx.Data["Email"] = email + + ctx.Data["IsResetRequest"] = true + ctx.HTML(http.StatusOK, tplForgotPassword) +} + +// ForgotPasswdPost response for forget password request +func ForgotPasswdPost(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("auth.forgot_password_title") + + if setting.MailService == nil { + ctx.NotFound("ForgotPasswdPost", nil) + return + } + ctx.Data["IsResetRequest"] = true + + email := ctx.Query("email") + ctx.Data["Email"] = email + + u, err := models.GetUserByEmail(email) + if err != nil { + if models.IsErrUserNotExist(err) { + ctx.Data["ResetPwdCodeLives"] = timeutil.MinutesToFriendly(setting.Service.ResetPwdCodeLives, ctx.Locale.Language()) + ctx.Data["IsResetSent"] = true + ctx.HTML(http.StatusOK, tplForgotPassword) + return + } + + ctx.ServerError("user.ResetPasswd(check existence)", err) + return + } + + if !u.IsLocal() && !u.IsOAuth2() { + ctx.Data["Err_Email"] = true + ctx.RenderWithErr(ctx.Tr("auth.non_local_account"), tplForgotPassword, nil) + return + } + + if ctx.Cache.IsExist("MailResendLimit_" + u.LowerName) { + ctx.Data["ResendLimited"] = true + ctx.HTML(http.StatusOK, tplForgotPassword) + return + } + + mailer.SendResetPasswordMail(u) + + if err = ctx.Cache.Put("MailResendLimit_"+u.LowerName, u.LowerName, 180); err != nil { + log.Error("Set cache(MailResendLimit) fail: %v", err) + } + + ctx.Data["ResetPwdCodeLives"] = timeutil.MinutesToFriendly(setting.Service.ResetPwdCodeLives, ctx.Locale.Language()) + ctx.Data["IsResetSent"] = true + ctx.HTML(http.StatusOK, tplForgotPassword) +} + +func commonResetPassword(ctx *context.Context) (*models.User, *models.TwoFactor) { + code := ctx.Query("code") + + ctx.Data["Title"] = ctx.Tr("auth.reset_password") + ctx.Data["Code"] = code + + if nil != ctx.User { + ctx.Data["user_signed_in"] = true + } + + if len(code) == 0 { + ctx.Flash.Error(ctx.Tr("auth.invalid_code")) + return nil, nil + } + + // Fail early, don't frustrate the user + u := models.VerifyUserActiveCode(code) + if u == nil { + ctx.Flash.Error(ctx.Tr("auth.invalid_code")) + return nil, nil + } + + twofa, err := models.GetTwoFactorByUID(u.ID) + if err != nil { + if !models.IsErrTwoFactorNotEnrolled(err) { + ctx.Error(http.StatusInternalServerError, "CommonResetPassword", err.Error()) + return nil, nil + } + } else { + ctx.Data["has_two_factor"] = true + ctx.Data["scratch_code"] = ctx.QueryBool("scratch_code") + } + + // Show the user that they are affecting the account that they intended to + ctx.Data["user_email"] = u.Email + + if nil != ctx.User && u.ID != ctx.User.ID { + ctx.Flash.Error(ctx.Tr("auth.reset_password_wrong_user", ctx.User.Email, u.Email)) + return nil, nil + } + + return u, twofa +} + +// ResetPasswd render the account recovery page +func ResetPasswd(ctx *context.Context) { + ctx.Data["IsResetForm"] = true + + commonResetPassword(ctx) + if ctx.Written() { + return + } + + ctx.HTML(http.StatusOK, tplResetPassword) +} + +// ResetPasswdPost response from account recovery request +func ResetPasswdPost(ctx *context.Context) { + u, twofa := commonResetPassword(ctx) + if ctx.Written() { + return + } + + if u == nil { + // Flash error has been set + ctx.HTML(http.StatusOK, tplResetPassword) + return + } + + // Validate password length. + passwd := ctx.Query("password") + if len(passwd) < setting.MinPasswordLength { + ctx.Data["IsResetForm"] = true + ctx.Data["Err_Password"] = true + ctx.RenderWithErr(ctx.Tr("auth.password_too_short", setting.MinPasswordLength), tplResetPassword, nil) + return + } else if !password.IsComplexEnough(passwd) { + ctx.Data["IsResetForm"] = true + ctx.Data["Err_Password"] = true + ctx.RenderWithErr(password.BuildComplexityError(ctx), tplResetPassword, nil) + return + } else if pwned, err := password.IsPwned(ctx, passwd); pwned || err != nil { + errMsg := ctx.Tr("auth.password_pwned") + if err != nil { + log.Error(err.Error()) + errMsg = ctx.Tr("auth.password_pwned_err") + } + ctx.Data["IsResetForm"] = true + ctx.Data["Err_Password"] = true + ctx.RenderWithErr(errMsg, tplResetPassword, nil) + return + } + + // Handle two-factor + regenerateScratchToken := false + if twofa != nil { + if ctx.QueryBool("scratch_code") { + if !twofa.VerifyScratchToken(ctx.Query("token")) { + ctx.Data["IsResetForm"] = true + ctx.Data["Err_Token"] = true + ctx.RenderWithErr(ctx.Tr("auth.twofa_scratch_token_incorrect"), tplResetPassword, nil) + return + } + regenerateScratchToken = true + } else { + passcode := ctx.Query("passcode") + ok, err := twofa.ValidateTOTP(passcode) + if err != nil { + ctx.Error(http.StatusInternalServerError, "ValidateTOTP", err.Error()) + return + } + if !ok || twofa.LastUsedPasscode == passcode { + ctx.Data["IsResetForm"] = true + ctx.Data["Err_Passcode"] = true + ctx.RenderWithErr(ctx.Tr("auth.twofa_passcode_incorrect"), tplResetPassword, nil) + return + } + + twofa.LastUsedPasscode = passcode + if err = models.UpdateTwoFactor(twofa); err != nil { + ctx.ServerError("ResetPasswdPost: UpdateTwoFactor", err) + return + } + } + } + var err error + if u.Rands, err = models.GetUserSalt(); err != nil { + ctx.ServerError("UpdateUser", err) + return + } + if err = u.SetPassword(passwd); err != nil { + ctx.ServerError("UpdateUser", err) + return + } + u.MustChangePassword = false + if err := models.UpdateUserCols(u, "must_change_password", "passwd", "passwd_hash_algo", "rands", "salt"); err != nil { + ctx.ServerError("UpdateUser", err) + return + } + + log.Trace("User password reset: %s", u.Name) + ctx.Data["IsResetFailed"] = true + remember := len(ctx.Query("remember")) != 0 + + if regenerateScratchToken { + // Invalidate the scratch token. + _, err = twofa.GenerateScratchToken() + if err != nil { + ctx.ServerError("UserSignIn", err) + return + } + if err = models.UpdateTwoFactor(twofa); err != nil { + ctx.ServerError("UserSignIn", err) + return + } + + handleSignInFull(ctx, u, remember, false) + ctx.Flash.Info(ctx.Tr("auth.twofa_scratch_used")) + ctx.Redirect(setting.AppSubURL + "/user/settings/security") + return + } + + handleSignInFull(ctx, u, remember, true) +} + +// MustChangePassword renders the page to change a user's password +func MustChangePassword(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("auth.must_change_password") + ctx.Data["ChangePasscodeLink"] = setting.AppSubURL + "/user/settings/change_password" + ctx.Data["MustChangePassword"] = true + ctx.HTML(http.StatusOK, tplMustChangePassword) +} + +// MustChangePasswordPost response for updating a user's password after his/her +// account was created by an admin +func MustChangePasswordPost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.MustChangePasswordForm) + ctx.Data["Title"] = ctx.Tr("auth.must_change_password") + ctx.Data["ChangePasscodeLink"] = setting.AppSubURL + "/user/settings/change_password" + if ctx.HasError() { + ctx.HTML(http.StatusOK, tplMustChangePassword) + return + } + u := ctx.User + // Make sure only requests for users who are eligible to change their password via + // this method passes through + if !u.MustChangePassword { + ctx.ServerError("MustUpdatePassword", errors.New("cannot update password.. Please visit the settings page")) + return + } + + if form.Password != form.Retype { + ctx.Data["Err_Password"] = true + ctx.RenderWithErr(ctx.Tr("form.password_not_match"), tplMustChangePassword, &form) + return + } + + if len(form.Password) < setting.MinPasswordLength { + ctx.Data["Err_Password"] = true + ctx.RenderWithErr(ctx.Tr("auth.password_too_short", setting.MinPasswordLength), tplMustChangePassword, &form) + return + } + + var err error + if err = u.SetPassword(form.Password); err != nil { + ctx.ServerError("UpdateUser", err) + return + } + + u.MustChangePassword = false + + if err := models.UpdateUserCols(u, "must_change_password", "passwd", "passwd_hash_algo", "salt"); err != nil { + ctx.ServerError("UpdateUser", err) + return + } + + ctx.Flash.Success(ctx.Tr("settings.change_password_success")) + + log.Trace("User updated password: %s", u.Name) + + if redirectTo := ctx.GetCookie("redirect_to"); len(redirectTo) > 0 && !utils.IsExternalURL(redirectTo) { + middleware.DeleteRedirectToCookie(ctx.Resp) + ctx.RedirectToFirst(redirectTo) + return + } + + ctx.Redirect(setting.AppSubURL + "/") +} diff --git a/routers/web/user/auth_openid.go b/routers/web/user/auth_openid.go new file mode 100644 index 0000000000..1a73a08c48 --- /dev/null +++ b/routers/web/user/auth_openid.go @@ -0,0 +1,450 @@ +// 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/http" + "net/url" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/auth/openid" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/hcaptcha" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/recaptcha" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/modules/web/middleware" + "code.gitea.io/gitea/services/forms" +) + +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.ServerError("AutoSignIn", err) + return + } + + redirectTo := ctx.Query("redirect_to") + if len(redirectTo) > 0 { + middleware.SetRedirectToCookie(ctx.Resp, redirectTo) + } else { + redirectTo = ctx.GetCookie("redirect_to") + } + + if isSucceed { + middleware.DeleteRedirectToCookie(ctx.Resp) + ctx.RedirectToFirst(redirectTo) + return + } + + ctx.Data["PageIsSignIn"] = true + ctx.Data["PageIsLoginOpenID"] = true + ctx.HTML(http.StatusOK, 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.Service.OpenIDWhitelist) != 0 { + for _, pat := range setting.Service.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.Service.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 := web.GetForm(ctx).(*forms.SignInOpenIDForm) + ctx.Data["Title"] = ctx.Tr("sign_in") + ctx.Data["PageIsSignIn"] = true + ctx.Data["PageIsLoginOpenID"] = true + + if ctx.HasError() { + ctx.HTML(http.StatusOK, 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 { + log.Error("Error in OpenID redirect URL: %s, %v", redirectTo, err.Error()) + ctx.RenderWithErr(fmt.Sprintf("Unable to find OpenID provider in %s", redirectTo), 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: %t", form.Remember) + + if err := ctx.Session.Set("openid_signin_remember", form.Remember); err != nil { + log.Error("SignInOpenIDPost: Could not set openid_signin_remember in session: %v", err) + } + if err := ctx.Session.Release(); err != nil { + log.Error("SignInOpenIDPost: Unable to save changes to the session: %v", err) + } + + ctx.Redirect(url) +} + +// signInOpenIDVerify handles response from OpenID provider +func signInOpenIDVerify(ctx *context.Context) { + + log.Trace("Incoming call to: " + ctx.Req.URL.String()) + + fullURL := setting.AppURL + ctx.Req.URL.String()[1:] + log.Trace("Full URL: " + fullURL) + + var id, err = openid.Verify(fullURL) + if err != nil { + ctx.RenderWithErr(err.Error(), tplSignInOpenID, &forms.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, err := models.GetUserByOpenID(id) + if err != nil { + if !models.IsErrUserNotExist(err) { + ctx.RenderWithErr(err.Error(), tplSignInOpenID, &forms.SignInOpenIDForm{ + Openid: id, + }) + return + } + log.Error("signInOpenIDVerify: %v", err) + } + if u != nil { + log.Trace("User exists, logging in") + remember, _ := ctx.Session.Get("openid_signin_remember").(bool) + log.Trace("Session stored openid-remember: %t", 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, &forms.SignInOpenIDForm{ + Openid: id, + }) + return + } + values, err := url.ParseQuery(parsedURL.RawQuery) + if err != nil { + ctx.RenderWithErr(err.Error(), tplSignInOpenID, &forms.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, err = models.GetUserByEmail(email) + if err != nil { + if !models.IsErrUserNotExist(err) { + ctx.RenderWithErr(err.Error(), tplSignInOpenID, &forms.SignInOpenIDForm{ + Openid: id, + }) + return + } + log.Error("signInOpenIDVerify: %v", err) + } + 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, &forms.SignInOpenIDForm{ + Openid: id, + }) + return + } + } + if u != nil { + log.Trace("Local user " + u.LowerName + " has OpenID provided nickname " + nickname) + } + } + + if err := ctx.Session.Set("openid_verified_uri", id); err != nil { + log.Error("signInOpenIDVerify: Could not set openid_verified_uri in session: %v", err) + } + if err := ctx.Session.Set("openid_determined_email", email); err != nil { + log.Error("signInOpenIDVerify: Could not set openid_determined_email in session: %v", err) + } + + if u != nil { + nickname = u.LowerName + } + + if err := ctx.Session.Set("openid_determined_username", nickname); err != nil { + log.Error("signInOpenIDVerify: Could not set openid_determined_username in session: %v", err) + } + if err := ctx.Session.Release(); err != nil { + log.Error("signInOpenIDVerify: Unable to save changes to the session: %v", err) + } + + if u != nil || !setting.Service.EnableOpenIDSignUp || setting.Service.AllowOnlyInternalRegistration { + 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.Service.EnableOpenIDSignUp + ctx.Data["AllowOnlyInternalRegistration"] = setting.Service.AllowOnlyInternalRegistration + ctx.Data["OpenID"] = oid + userName, _ := ctx.Session.Get("openid_determined_username").(string) + if userName != "" { + ctx.Data["user_name"] = userName + } + ctx.HTML(http.StatusOK, tplConnectOID) +} + +// ConnectOpenIDPost handles submission of a form to connect an OpenID URI to an existing account +func ConnectOpenIDPost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.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.Service.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.ServerError("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.ServerError("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: %t", remember) + handleSignIn(ctx, u, remember) +} + +// RegisterOpenID shows a form to create a new user authenticated via an OpenID URI +func RegisterOpenID(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 signup" + ctx.Data["PageIsSignIn"] = true + ctx.Data["PageIsOpenIDRegister"] = true + ctx.Data["EnableOpenIDSignUp"] = setting.Service.EnableOpenIDSignUp + ctx.Data["AllowOnlyInternalRegistration"] = setting.Service.AllowOnlyInternalRegistration + ctx.Data["EnableCaptcha"] = setting.Service.EnableCaptcha + ctx.Data["Captcha"] = context.GetImageCaptcha() + ctx.Data["CaptchaType"] = setting.Service.CaptchaType + ctx.Data["RecaptchaSitekey"] = setting.Service.RecaptchaSitekey + ctx.Data["HcaptchaSitekey"] = setting.Service.HcaptchaSitekey + ctx.Data["RecaptchaURL"] = setting.Service.RecaptchaURL + 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(http.StatusOK, tplSignUpOID) +} + +// RegisterOpenIDPost handles submission of a form to create a new user authenticated via an OpenID URI +func RegisterOpenIDPost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.SignUpOpenIDForm) + 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.Service.EnableOpenIDSignUp + ctx.Data["EnableCaptcha"] = setting.Service.EnableCaptcha + ctx.Data["RecaptchaURL"] = setting.Service.RecaptchaURL + ctx.Data["Captcha"] = context.GetImageCaptcha() + ctx.Data["CaptchaType"] = setting.Service.CaptchaType + ctx.Data["RecaptchaSitekey"] = setting.Service.RecaptchaSitekey + ctx.Data["HcaptchaSitekey"] = setting.Service.HcaptchaSitekey + ctx.Data["OpenID"] = oid + + if setting.Service.AllowOnlyInternalRegistration { + ctx.Error(http.StatusForbidden) + return + } + + if setting.Service.EnableCaptcha { + var valid bool + var err error + switch setting.Service.CaptchaType { + case setting.ImageCaptcha: + valid = context.GetImageCaptcha().VerifyReq(ctx.Req) + case setting.ReCaptcha: + if err := ctx.Req.ParseForm(); err != nil { + ctx.ServerError("", err) + return + } + valid, err = recaptcha.Verify(ctx, form.GRecaptchaResponse) + case setting.HCaptcha: + if err := ctx.Req.ParseForm(); err != nil { + ctx.ServerError("", err) + return + } + valid, err = hcaptcha.Verify(ctx, form.HcaptchaResponse) + default: + ctx.ServerError("Unknown Captcha Type", fmt.Errorf("Unknown Captcha Type: %s", setting.Service.CaptchaType)) + return + } + if err != nil { + log.Debug("%s", err.Error()) + } + + if !valid { + ctx.Data["Err_Captcha"] = true + ctx.RenderWithErr(ctx.Tr("form.captcha_incorrect"), tplSignUpOID, &form) + return + } + } + + length := setting.MinPasswordLength + if length < 256 { + length = 256 + } + password, err := util.RandomString(int64(length)) + if err != nil { + ctx.RenderWithErr(err.Error(), tplSignUpOID, form) + return + } + + u := &models.User{ + Name: form.UserName, + Email: form.Email, + Passwd: password, + IsActive: !(setting.Service.RegisterEmailConfirm || setting.Service.RegisterManualConfirm), + } + if !createUserInContext(ctx, tplSignUpOID, form, u, nil, false) { + // error already handled + 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), tplSignUpOID, &form) + return + } + ctx.ServerError("AddUserOpenID", err) + return + } + + if !handleUserCreated(ctx, u, nil) { + // error already handled + return + } + + remember, _ := ctx.Session.Get("openid_signin_remember").(bool) + log.Trace("Session stored openid-remember: %t", remember) + handleSignIn(ctx, u, remember) +} diff --git a/routers/web/user/avatar.go b/routers/web/user/avatar.go new file mode 100644 index 0000000000..4287589d1a --- /dev/null +++ b/routers/web/user/avatar.go @@ -0,0 +1,98 @@ +// Copyright 2019 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 ( + "errors" + "net/url" + "path" + "strconv" + "strings" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" +) + +// Avatar redirect browser to user avatar of requested size +func Avatar(ctx *context.Context) { + userName := ctx.Params(":username") + size, err := strconv.Atoi(ctx.Params(":size")) + if err != nil { + ctx.ServerError("Invalid avatar size", err) + return + } + + log.Debug("Asked avatar for user %v and size %v", userName, size) + + var user *models.User + if strings.ToLower(userName) != "ghost" { + user, err = models.GetUserByName(userName) + if err != nil { + if models.IsErrUserNotExist(err) { + ctx.ServerError("Requested avatar for invalid user", err) + } else { + ctx.ServerError("Retrieving user by name", err) + } + return + } + } else { + user = models.NewGhostUser() + } + + ctx.Redirect(user.RealSizedAvatarLink(size)) +} + +// AvatarByEmailHash redirects the browser to the appropriate Avatar link +func AvatarByEmailHash(ctx *context.Context) { + var err error + + hash := ctx.Params(":hash") + if len(hash) == 0 { + ctx.ServerError("invalid avatar hash", errors.New("hash cannot be empty")) + return + } + + var email string + email, err = models.GetEmailForHash(hash) + if err != nil { + ctx.ServerError("invalid avatar hash", err) + return + } + if len(email) == 0 { + ctx.Redirect(models.DefaultAvatarLink()) + return + } + size := ctx.QueryInt("size") + if size == 0 { + size = models.DefaultAvatarSize + } + + var avatarURL *url.URL + + if setting.EnableFederatedAvatar && setting.LibravatarService != nil { + avatarURL, err = models.LibravatarURL(email) + if err != nil { + avatarURL, err = url.Parse(models.DefaultAvatarLink()) + if err != nil { + ctx.ServerError("invalid default avatar url", err) + return + } + } + } else if !setting.DisableGravatar { + copyOfGravatarSourceURL := *setting.GravatarSourceURL + avatarURL = ©OfGravatarSourceURL + avatarURL.Path = path.Join(avatarURL.Path, hash) + } else { + avatarURL, err = url.Parse(models.DefaultAvatarLink()) + if err != nil { + ctx.ServerError("invalid default avatar url", err) + return + } + } + + ctx.Redirect(models.MakeFinalAvatarURL(avatarURL, size)) +} diff --git a/routers/web/user/home.go b/routers/web/user/home.go new file mode 100644 index 0000000000..acf73f82fe --- /dev/null +++ b/routers/web/user/home.go @@ -0,0 +1,913 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// Copyright 2019 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 ( + "bytes" + "fmt" + "net/http" + "regexp" + "sort" + "strconv" + "strings" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/context" + issue_indexer "code.gitea.io/gitea/modules/indexer/issues" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/markup" + "code.gitea.io/gitea/modules/markup/markdown" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/util" + issue_service "code.gitea.io/gitea/services/issue" + pull_service "code.gitea.io/gitea/services/pull" + + jsoniter "github.com/json-iterator/go" + "github.com/keybase/go-crypto/openpgp" + "github.com/keybase/go-crypto/openpgp/armor" + "xorm.io/builder" +) + +const ( + tplDashboard base.TplName = "user/dashboard/dashboard" + tplIssues base.TplName = "user/dashboard/issues" + tplMilestones base.TplName = "user/dashboard/milestones" + tplProfile base.TplName = "user/profile" +) + +// getDashboardContextUser finds out which context user dashboard is being viewed as . +func getDashboardContextUser(ctx *context.Context) *models.User { + ctxUser := ctx.User + orgName := ctx.Params(":org") + if len(orgName) > 0 { + ctxUser = ctx.Org.Organization + ctx.Data["Teams"] = ctx.Org.Organization.Teams + } + ctx.Data["ContextUser"] = ctxUser + + if err := ctx.User.GetOrganizations(&models.SearchOrganizationsOptions{All: true}); err != nil { + ctx.ServerError("GetOrganizations", err) + return nil + } + ctx.Data["Orgs"] = ctx.User.Orgs + + return ctxUser +} + +// retrieveFeeds loads feeds for the specified user +func retrieveFeeds(ctx *context.Context, options models.GetFeedsOptions) { + actions, err := models.GetFeeds(options) + if err != nil { + ctx.ServerError("GetFeeds", err) + return + } + + userCache := map[int64]*models.User{options.RequestedUser.ID: options.RequestedUser} + if ctx.User != nil { + userCache[ctx.User.ID] = ctx.User + } + for _, act := range actions { + if act.ActUser != nil { + userCache[act.ActUserID] = act.ActUser + } + } + + for _, act := range actions { + repoOwner, ok := userCache[act.Repo.OwnerID] + if !ok { + repoOwner, err = models.GetUserByID(act.Repo.OwnerID) + if err != nil { + if models.IsErrUserNotExist(err) { + continue + } + ctx.ServerError("GetUserByID", err) + return + } + userCache[repoOwner.ID] = repoOwner + } + act.Repo.Owner = repoOwner + } + ctx.Data["Feeds"] = actions +} + +// Dashboard render the dashboard page +func Dashboard(ctx *context.Context) { + ctxUser := getDashboardContextUser(ctx) + if ctx.Written() { + return + } + + ctx.Data["Title"] = ctxUser.DisplayName() + " - " + ctx.Tr("dashboard") + ctx.Data["PageIsDashboard"] = true + ctx.Data["PageIsNews"] = true + ctx.Data["SearchLimit"] = setting.UI.User.RepoPagingNum + + if setting.Service.EnableUserHeatmap { + data, err := models.GetUserHeatmapDataByUserTeam(ctxUser, ctx.Org.Team, ctx.User) + if err != nil { + ctx.ServerError("GetUserHeatmapDataByUserTeam", err) + return + } + ctx.Data["HeatmapData"] = data + } + + var err error + var mirrors []*models.Repository + if ctxUser.IsOrganization() { + var env models.AccessibleReposEnvironment + if ctx.Org.Team != nil { + env = ctxUser.AccessibleTeamReposEnv(ctx.Org.Team) + } else { + env, err = ctxUser.AccessibleReposEnv(ctx.User.ID) + if err != nil { + ctx.ServerError("AccessibleReposEnv", err) + return + } + } + mirrors, err = env.MirrorRepos() + if err != nil { + ctx.ServerError("env.MirrorRepos", err) + return + } + } else { + mirrors, err = ctxUser.GetMirrorRepositories() + if err != nil { + ctx.ServerError("GetMirrorRepositories", err) + return + } + } + ctx.Data["MaxShowRepoNum"] = setting.UI.User.RepoPagingNum + + if err := models.MirrorRepositoryList(mirrors).LoadAttributes(); err != nil { + ctx.ServerError("MirrorRepositoryList.LoadAttributes", err) + return + } + ctx.Data["MirrorCount"] = len(mirrors) + ctx.Data["Mirrors"] = mirrors + + retrieveFeeds(ctx, models.GetFeedsOptions{ + RequestedUser: ctxUser, + RequestedTeam: ctx.Org.Team, + Actor: ctx.User, + IncludePrivate: true, + OnlyPerformedBy: false, + IncludeDeleted: false, + Date: ctx.Query("date"), + }) + + if ctx.Written() { + return + } + ctx.HTML(http.StatusOK, tplDashboard) +} + +// Milestones render the user milestones page +func Milestones(ctx *context.Context) { + if models.UnitTypeIssues.UnitGlobalDisabled() && models.UnitTypePullRequests.UnitGlobalDisabled() { + log.Debug("Milestones overview page not available as both issues and pull requests are globally disabled") + ctx.Status(404) + return + } + + ctx.Data["Title"] = ctx.Tr("milestones") + ctx.Data["PageIsMilestonesDashboard"] = true + + ctxUser := getDashboardContextUser(ctx) + if ctx.Written() { + return + } + + repoOpts := models.SearchRepoOptions{ + Actor: ctxUser, + OwnerID: ctxUser.ID, + Private: true, + AllPublic: false, // Include also all public repositories of users and public organisations + AllLimited: false, // Include also all public repositories of limited organisations + HasMilestones: util.OptionalBoolTrue, // Just needs display repos has milestones + } + + if ctxUser.IsOrganization() && ctx.Org.Team != nil { + repoOpts.TeamID = ctx.Org.Team.ID + } + + var ( + userRepoCond = models.SearchRepositoryCondition(&repoOpts) // all repo condition user could visit + repoCond = userRepoCond + repoIDs []int64 + + reposQuery = ctx.Query("repos") + isShowClosed = ctx.Query("state") == "closed" + sortType = ctx.Query("sort") + page = ctx.QueryInt("page") + keyword = strings.Trim(ctx.Query("q"), " ") + ) + + if page <= 1 { + page = 1 + } + + if len(reposQuery) != 0 { + if issueReposQueryPattern.MatchString(reposQuery) { + // remove "[" and "]" from string + reposQuery = reposQuery[1 : len(reposQuery)-1] + //for each ID (delimiter ",") add to int to repoIDs + + for _, rID := range strings.Split(reposQuery, ",") { + // Ensure nonempty string entries + if rID != "" && rID != "0" { + rIDint64, err := strconv.ParseInt(rID, 10, 64) + // If the repo id specified by query is not parseable or not accessible by user, just ignore it. + if err == nil { + repoIDs = append(repoIDs, rIDint64) + } + } + } + if len(repoIDs) > 0 { + // Don't just let repoCond = builder.In("id", repoIDs) because user may has no permission on repoIDs + // But the original repoCond has a limitation + repoCond = repoCond.And(builder.In("id", repoIDs)) + } + } else { + log.Warn("issueReposQueryPattern not match with query") + } + } + + counts, err := models.CountMilestonesByRepoCondAndKw(userRepoCond, keyword, isShowClosed) + if err != nil { + ctx.ServerError("CountMilestonesByRepoIDs", err) + return + } + + milestones, err := models.SearchMilestones(repoCond, page, isShowClosed, sortType, keyword) + if err != nil { + ctx.ServerError("SearchMilestones", err) + return + } + + showRepos, _, err := models.SearchRepositoryByCondition(&repoOpts, userRepoCond, false) + if err != nil { + ctx.ServerError("SearchRepositoryByCondition", err) + return + } + sort.Sort(showRepos) + + for i := 0; i < len(milestones); { + for _, repo := range showRepos { + if milestones[i].RepoID == repo.ID { + milestones[i].Repo = repo + break + } + } + if milestones[i].Repo == nil { + log.Warn("Cannot find milestone %d 's repository %d", milestones[i].ID, milestones[i].RepoID) + milestones = append(milestones[:i], milestones[i+1:]...) + continue + } + + milestones[i].RenderedContent, err = markdown.RenderString(&markup.RenderContext{ + URLPrefix: milestones[i].Repo.Link(), + Metas: milestones[i].Repo.ComposeMetas(), + }, milestones[i].Content) + if err != nil { + ctx.ServerError("RenderString", err) + return + } + + if milestones[i].Repo.IsTimetrackerEnabled() { + err := milestones[i].LoadTotalTrackedTime() + if err != nil { + ctx.ServerError("LoadTotalTrackedTime", err) + return + } + } + i++ + } + + milestoneStats, err := models.GetMilestonesStatsByRepoCondAndKw(repoCond, keyword) + if err != nil { + ctx.ServerError("GetMilestoneStats", err) + return + } + + var totalMilestoneStats *models.MilestonesStats + if len(repoIDs) == 0 { + totalMilestoneStats = milestoneStats + } else { + totalMilestoneStats, err = models.GetMilestonesStatsByRepoCondAndKw(userRepoCond, keyword) + if err != nil { + ctx.ServerError("GetMilestoneStats", err) + return + } + } + + var pagerCount int + if isShowClosed { + ctx.Data["State"] = "closed" + ctx.Data["Total"] = totalMilestoneStats.ClosedCount + pagerCount = int(milestoneStats.ClosedCount) + } else { + ctx.Data["State"] = "open" + ctx.Data["Total"] = totalMilestoneStats.OpenCount + pagerCount = int(milestoneStats.OpenCount) + } + + ctx.Data["Milestones"] = milestones + ctx.Data["Repos"] = showRepos + ctx.Data["Counts"] = counts + ctx.Data["MilestoneStats"] = milestoneStats + ctx.Data["SortType"] = sortType + ctx.Data["Keyword"] = keyword + if milestoneStats.Total() != totalMilestoneStats.Total() { + ctx.Data["RepoIDs"] = repoIDs + } + ctx.Data["IsShowClosed"] = isShowClosed + + pager := context.NewPagination(pagerCount, setting.UI.IssuePagingNum, page, 5) + pager.AddParam(ctx, "q", "Keyword") + pager.AddParam(ctx, "repos", "RepoIDs") + pager.AddParam(ctx, "sort", "SortType") + pager.AddParam(ctx, "state", "State") + ctx.Data["Page"] = pager + + ctx.HTML(http.StatusOK, tplMilestones) +} + +// Pulls renders the user's pull request overview page +func Pulls(ctx *context.Context) { + if models.UnitTypePullRequests.UnitGlobalDisabled() { + log.Debug("Pull request overview page not available as it is globally disabled.") + ctx.Status(404) + return + } + + ctx.Data["Title"] = ctx.Tr("pull_requests") + ctx.Data["PageIsPulls"] = true + buildIssueOverview(ctx, models.UnitTypePullRequests) +} + +// Issues renders the user's issues overview page +func Issues(ctx *context.Context) { + if models.UnitTypeIssues.UnitGlobalDisabled() { + log.Debug("Issues overview page not available as it is globally disabled.") + ctx.Status(404) + return + } + + ctx.Data["Title"] = ctx.Tr("issues") + ctx.Data["PageIsIssues"] = true + buildIssueOverview(ctx, models.UnitTypeIssues) +} + +// Regexp for repos query +var issueReposQueryPattern = regexp.MustCompile(`^\[\d+(,\d+)*,?\]$`) + +func buildIssueOverview(ctx *context.Context, unitType models.UnitType) { + + // ---------------------------------------------------- + // Determine user; can be either user or organization. + // Return with NotFound or ServerError if unsuccessful. + // ---------------------------------------------------- + + ctxUser := getDashboardContextUser(ctx) + if ctx.Written() { + return + } + + var ( + viewType string + sortType = ctx.Query("sort") + filterMode = models.FilterModeAll + ) + + // -------------------------------------------------------------------------------- + // Distinguish User from Organization. + // Org: + // - Remember pre-determined viewType string for later. Will be posted to ctx.Data. + // Organization does not have view type and filter mode. + // User: + // - Use ctx.Query("type") to determine filterMode. + // The type is set when clicking for example "assigned to me" on the overview page. + // - Remember either this or a fallback. Will be posted to ctx.Data. + // -------------------------------------------------------------------------------- + + // TODO: distinguish during routing + + viewType = ctx.Query("type") + switch viewType { + case "assigned": + filterMode = models.FilterModeAssign + case "created_by": + filterMode = models.FilterModeCreate + case "mentioned": + filterMode = models.FilterModeMention + case "review_requested": + filterMode = models.FilterModeReviewRequested + case "your_repositories": // filterMode already set to All + default: + viewType = "your_repositories" + } + + // -------------------------------------------------------------------------- + // Build opts (IssuesOptions), which contains filter information. + // Will eventually be used to retrieve issues relevant for the overview page. + // Note: Non-final states of opts are used in-between, namely for: + // - Keyword search + // - Count Issues by repo + // -------------------------------------------------------------------------- + + isPullList := unitType == models.UnitTypePullRequests + opts := &models.IssuesOptions{ + IsPull: util.OptionalBoolOf(isPullList), + SortType: sortType, + IsArchived: util.OptionalBoolFalse, + } + + // Get repository IDs where User/Org/Team has access. + var team *models.Team + if ctx.Org != nil { + team = ctx.Org.Team + } + userRepoIDs, err := getActiveUserRepoIDs(ctxUser, team, unitType) + if err != nil { + ctx.ServerError("userRepoIDs", err) + return + } + + switch filterMode { + case models.FilterModeAll: + opts.RepoIDs = userRepoIDs + case models.FilterModeAssign: + opts.AssigneeID = ctx.User.ID + case models.FilterModeCreate: + opts.PosterID = ctx.User.ID + case models.FilterModeMention: + opts.MentionedID = ctx.User.ID + case models.FilterModeReviewRequested: + opts.ReviewRequestedID = ctx.User.ID + } + + if ctxUser.IsOrganization() { + opts.RepoIDs = userRepoIDs + } + + // keyword holds the search term entered into the search field. + keyword := strings.Trim(ctx.Query("q"), " ") + ctx.Data["Keyword"] = keyword + + // Execute keyword search for issues. + // USING NON-FINAL STATE OF opts FOR A QUERY. + issueIDsFromSearch, err := issueIDsFromSearch(ctxUser, keyword, opts) + if err != nil { + ctx.ServerError("issueIDsFromSearch", err) + return + } + + // Ensure no issues are returned if a keyword was provided that didn't match any issues. + var forceEmpty bool + + if len(issueIDsFromSearch) > 0 { + opts.IssueIDs = issueIDsFromSearch + } else if len(keyword) > 0 { + forceEmpty = true + } + + // Educated guess: Do or don't show closed issues. + isShowClosed := ctx.Query("state") == "closed" + opts.IsClosed = util.OptionalBoolOf(isShowClosed) + + // Filter repos and count issues in them. Count will be used later. + // USING NON-FINAL STATE OF opts FOR A QUERY. + var issueCountByRepo map[int64]int64 + if !forceEmpty { + issueCountByRepo, err = models.CountIssuesByRepo(opts) + if err != nil { + ctx.ServerError("CountIssuesByRepo", err) + return + } + } + + // Make sure page number is at least 1. Will be posted to ctx.Data. + page := ctx.QueryInt("page") + if page <= 1 { + page = 1 + } + opts.Page = page + opts.PageSize = setting.UI.IssuePagingNum + + // Get IDs for labels (a filter option for issues/pulls). + // Required for IssuesOptions. + var labelIDs []int64 + selectedLabels := ctx.Query("labels") + if len(selectedLabels) > 0 && selectedLabels != "0" { + labelIDs, err = base.StringsToInt64s(strings.Split(selectedLabels, ",")) + if err != nil { + ctx.ServerError("StringsToInt64s", err) + return + } + } + opts.LabelIDs = labelIDs + + // Parse ctx.Query("repos") and remember matched repo IDs for later. + // Gets set when clicking filters on the issues overview page. + repoIDs := getRepoIDs(ctx.Query("repos")) + if len(repoIDs) > 0 { + opts.RepoIDs = repoIDs + } + + // ------------------------------ + // Get issues as defined by opts. + // ------------------------------ + + // Slice of Issues that will be displayed on the overview page + // USING FINAL STATE OF opts FOR A QUERY. + var issues []*models.Issue + if !forceEmpty { + issues, err = models.Issues(opts) + if err != nil { + ctx.ServerError("Issues", err) + return + } + } else { + issues = []*models.Issue{} + } + + // ---------------------------------- + // Add repository pointers to Issues. + // ---------------------------------- + + // showReposMap maps repository IDs to their Repository pointers. + showReposMap, err := repoIDMap(ctxUser, issueCountByRepo, unitType) + if err != nil { + if models.IsErrRepoNotExist(err) { + ctx.NotFound("GetRepositoryByID", err) + return + } + ctx.ServerError("repoIDMap", err) + return + } + + // a RepositoryList + showRepos := models.RepositoryListOfMap(showReposMap) + sort.Sort(showRepos) + if err = showRepos.LoadAttributes(); err != nil { + ctx.ServerError("LoadAttributes", err) + return + } + + // maps pull request IDs to their CommitStatus. Will be posted to ctx.Data. + for _, issue := range issues { + issue.Repo = showReposMap[issue.RepoID] + } + + commitStatus, err := pull_service.GetIssuesLastCommitStatus(issues) + if err != nil { + ctx.ServerError("GetIssuesLastCommitStatus", err) + return + } + + // ------------------------------- + // Fill stats to post to ctx.Data. + // ------------------------------- + + userIssueStatsOpts := models.UserIssueStatsOptions{ + UserID: ctx.User.ID, + UserRepoIDs: userRepoIDs, + FilterMode: filterMode, + IsPull: isPullList, + IsClosed: isShowClosed, + IsArchived: util.OptionalBoolFalse, + LabelIDs: opts.LabelIDs, + } + if len(repoIDs) > 0 { + userIssueStatsOpts.UserRepoIDs = repoIDs + } + if ctxUser.IsOrganization() { + userIssueStatsOpts.RepoIDs = userRepoIDs + } + userIssueStats, err := models.GetUserIssueStats(userIssueStatsOpts) + if err != nil { + ctx.ServerError("GetUserIssueStats User", err) + return + } + + var shownIssueStats *models.IssueStats + if !forceEmpty { + statsOpts := models.UserIssueStatsOptions{ + UserID: ctx.User.ID, + UserRepoIDs: userRepoIDs, + FilterMode: filterMode, + IsPull: isPullList, + IsClosed: isShowClosed, + IssueIDs: issueIDsFromSearch, + IsArchived: util.OptionalBoolFalse, + LabelIDs: opts.LabelIDs, + } + if len(repoIDs) > 0 { + statsOpts.RepoIDs = repoIDs + } else if ctxUser.IsOrganization() { + statsOpts.RepoIDs = userRepoIDs + } + shownIssueStats, err = models.GetUserIssueStats(statsOpts) + if err != nil { + ctx.ServerError("GetUserIssueStats Shown", err) + return + } + } else { + shownIssueStats = &models.IssueStats{} + } + + var allIssueStats *models.IssueStats + if !forceEmpty { + allIssueStatsOpts := models.UserIssueStatsOptions{ + UserID: ctx.User.ID, + UserRepoIDs: userRepoIDs, + FilterMode: filterMode, + IsPull: isPullList, + IsClosed: isShowClosed, + IssueIDs: issueIDsFromSearch, + IsArchived: util.OptionalBoolFalse, + LabelIDs: opts.LabelIDs, + } + if ctxUser.IsOrganization() { + allIssueStatsOpts.RepoIDs = userRepoIDs + } + allIssueStats, err = models.GetUserIssueStats(allIssueStatsOpts) + if err != nil { + ctx.ServerError("GetUserIssueStats All", err) + return + } + } else { + allIssueStats = &models.IssueStats{} + } + + // Will be posted to ctx.Data. + var shownIssues int + if !isShowClosed { + shownIssues = int(shownIssueStats.OpenCount) + ctx.Data["TotalIssueCount"] = int(allIssueStats.OpenCount) + } else { + shownIssues = int(shownIssueStats.ClosedCount) + ctx.Data["TotalIssueCount"] = int(allIssueStats.ClosedCount) + } + + ctx.Data["IsShowClosed"] = isShowClosed + + ctx.Data["IssueRefEndNames"], ctx.Data["IssueRefURLs"] = + issue_service.GetRefEndNamesAndURLs(issues, ctx.Query("RepoLink")) + + ctx.Data["Issues"] = issues + + approvalCounts, err := models.IssueList(issues).GetApprovalCounts() + if err != nil { + ctx.ServerError("ApprovalCounts", err) + return + } + ctx.Data["ApprovalCounts"] = func(issueID int64, typ string) int64 { + counts, ok := approvalCounts[issueID] + if !ok || len(counts) == 0 { + return 0 + } + reviewTyp := models.ReviewTypeApprove + if typ == "reject" { + reviewTyp = models.ReviewTypeReject + } else if typ == "waiting" { + reviewTyp = models.ReviewTypeRequest + } + for _, count := range counts { + if count.Type == reviewTyp { + return count.Count + } + } + return 0 + } + ctx.Data["CommitStatus"] = commitStatus + ctx.Data["Repos"] = showRepos + ctx.Data["Counts"] = issueCountByRepo + ctx.Data["IssueStats"] = userIssueStats + ctx.Data["ShownIssueStats"] = shownIssueStats + ctx.Data["ViewType"] = viewType + ctx.Data["SortType"] = sortType + ctx.Data["RepoIDs"] = repoIDs + ctx.Data["IsShowClosed"] = isShowClosed + ctx.Data["SelectLabels"] = selectedLabels + + if isShowClosed { + ctx.Data["State"] = "closed" + } else { + ctx.Data["State"] = "open" + } + + // Convert []int64 to string + json := jsoniter.ConfigCompatibleWithStandardLibrary + reposParam, _ := json.Marshal(repoIDs) + + ctx.Data["ReposParam"] = string(reposParam) + + pager := context.NewPagination(shownIssues, setting.UI.IssuePagingNum, page, 5) + pager.AddParam(ctx, "q", "Keyword") + pager.AddParam(ctx, "type", "ViewType") + pager.AddParam(ctx, "repos", "ReposParam") + pager.AddParam(ctx, "sort", "SortType") + pager.AddParam(ctx, "state", "State") + pager.AddParam(ctx, "labels", "SelectLabels") + pager.AddParam(ctx, "milestone", "MilestoneID") + pager.AddParam(ctx, "assignee", "AssigneeID") + ctx.Data["Page"] = pager + + ctx.HTML(http.StatusOK, tplIssues) +} + +func getRepoIDs(reposQuery string) []int64 { + if len(reposQuery) == 0 || reposQuery == "[]" { + return []int64{} + } + if !issueReposQueryPattern.MatchString(reposQuery) { + log.Warn("issueReposQueryPattern does not match query") + return []int64{} + } + + var repoIDs []int64 + // remove "[" and "]" from string + reposQuery = reposQuery[1 : len(reposQuery)-1] + //for each ID (delimiter ",") add to int to repoIDs + for _, rID := range strings.Split(reposQuery, ",") { + // Ensure nonempty string entries + if rID != "" && rID != "0" { + rIDint64, err := strconv.ParseInt(rID, 10, 64) + if err == nil { + repoIDs = append(repoIDs, rIDint64) + } + } + } + + return repoIDs +} + +func getActiveUserRepoIDs(ctxUser *models.User, team *models.Team, unitType models.UnitType) ([]int64, error) { + var userRepoIDs []int64 + var err error + + if ctxUser.IsOrganization() { + userRepoIDs, err = getActiveTeamOrOrgRepoIds(ctxUser, team, unitType) + if err != nil { + return nil, fmt.Errorf("orgRepoIds: %v", err) + } + } else { + userRepoIDs, err = ctxUser.GetActiveAccessRepoIDs(unitType) + if err != nil { + return nil, fmt.Errorf("ctxUser.GetAccessRepoIDs: %v", err) + } + } + + if len(userRepoIDs) == 0 { + userRepoIDs = []int64{-1} + } + + return userRepoIDs, nil +} + +// getActiveTeamOrOrgRepoIds gets RepoIDs for ctxUser as Organization. +// Should be called if and only if ctxUser.IsOrganization == true. +func getActiveTeamOrOrgRepoIds(ctxUser *models.User, team *models.Team, unitType models.UnitType) ([]int64, error) { + var orgRepoIDs []int64 + var err error + var env models.AccessibleReposEnvironment + + if team != nil { + env = ctxUser.AccessibleTeamReposEnv(team) + } else { + env, err = ctxUser.AccessibleReposEnv(ctxUser.ID) + if err != nil { + return nil, fmt.Errorf("AccessibleReposEnv: %v", err) + } + } + orgRepoIDs, err = env.RepoIDs(1, ctxUser.NumRepos) + if err != nil { + return nil, fmt.Errorf("env.RepoIDs: %v", err) + } + orgRepoIDs, err = models.FilterOutRepoIdsWithoutUnitAccess(ctxUser, orgRepoIDs, unitType) + if err != nil { + return nil, fmt.Errorf("FilterOutRepoIdsWithoutUnitAccess: %v", err) + } + + return orgRepoIDs, nil +} + +func issueIDsFromSearch(ctxUser *models.User, keyword string, opts *models.IssuesOptions) ([]int64, error) { + if len(keyword) == 0 { + return []int64{}, nil + } + + searchRepoIDs, err := models.GetRepoIDsForIssuesOptions(opts, ctxUser) + if err != nil { + return nil, fmt.Errorf("GetRepoIDsForIssuesOptions: %v", err) + } + issueIDsFromSearch, err := issue_indexer.SearchIssuesByKeyword(searchRepoIDs, keyword) + if err != nil { + return nil, fmt.Errorf("SearchIssuesByKeyword: %v", err) + } + + return issueIDsFromSearch, nil +} + +func repoIDMap(ctxUser *models.User, issueCountByRepo map[int64]int64, unitType models.UnitType) (map[int64]*models.Repository, error) { + repoByID := make(map[int64]*models.Repository, len(issueCountByRepo)) + for id := range issueCountByRepo { + if id <= 0 { + continue + } + if _, ok := repoByID[id]; !ok { + repo, err := models.GetRepositoryByID(id) + if models.IsErrRepoNotExist(err) { + return nil, err + } else if err != nil { + return nil, fmt.Errorf("GetRepositoryByID: [%d]%v", id, err) + } + repoByID[id] = repo + } + repo := repoByID[id] + + // Check if user has access to given repository. + perm, err := models.GetUserRepoPermission(repo, ctxUser) + if err != nil { + return nil, fmt.Errorf("GetUserRepoPermission: [%d]%v", id, err) + } + if !perm.CanRead(unitType) { + log.Debug("User created Issues in Repository which they no longer have access to: [%d]", id) + } + } + return repoByID, nil +} + +// ShowSSHKeys output all the ssh keys of user by uid +func ShowSSHKeys(ctx *context.Context, uid int64) { + keys, err := models.ListPublicKeys(uid, models.ListOptions{}) + if err != nil { + ctx.ServerError("ListPublicKeys", err) + return + } + + var buf bytes.Buffer + for i := range keys { + buf.WriteString(keys[i].OmitEmail()) + buf.WriteString("\n") + } + ctx.PlainText(200, buf.Bytes()) +} + +// ShowGPGKeys output all the public GPG keys of user by uid +func ShowGPGKeys(ctx *context.Context, uid int64) { + keys, err := models.ListGPGKeys(uid, models.ListOptions{}) + if err != nil { + ctx.ServerError("ListGPGKeys", err) + return + } + entities := make([]*openpgp.Entity, 0) + failedEntitiesID := make([]string, 0) + for _, k := range keys { + e, err := models.GPGKeyToEntity(k) + if err != nil { + if models.IsErrGPGKeyImportNotExist(err) { + failedEntitiesID = append(failedEntitiesID, k.KeyID) + continue //Skip previous import without backup of imported armored key + } + ctx.ServerError("ShowGPGKeys", err) + return + } + entities = append(entities, e) + } + var buf bytes.Buffer + + headers := make(map[string]string) + if len(failedEntitiesID) > 0 { //If some key need re-import to be exported + headers["Note"] = fmt.Sprintf("The keys with the following IDs couldn't be exported and need to be reuploaded %s", strings.Join(failedEntitiesID, ", ")) + } + writer, _ := armor.Encode(&buf, "PGP PUBLIC KEY BLOCK", headers) + for _, e := range entities { + err = e.Serialize(writer) //TODO find why key are exported with a different cipherTypeByte as original (should not be blocking but strange) + if err != nil { + ctx.ServerError("ShowGPGKeys", err) + return + } + } + writer.Close() + ctx.PlainText(200, buf.Bytes()) +} + +// Email2User show user page via email +func Email2User(ctx *context.Context) { + u, err := models.GetUserByEmail(ctx.Query("email")) + if err != nil { + if models.IsErrUserNotExist(err) { + ctx.NotFound("GetUserByEmail", err) + } else { + ctx.ServerError("GetUserByEmail", err) + } + return + } + ctx.Redirect(setting.AppSubURL + "/user/" + u.Name) +} diff --git a/routers/web/user/home_test.go b/routers/web/user/home_test.go new file mode 100644 index 0000000000..b0109c354f --- /dev/null +++ b/routers/web/user/home_test.go @@ -0,0 +1,118 @@ +// 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 ( + "net/http" + "testing" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/test" + + "github.com/stretchr/testify/assert" +) + +func TestArchivedIssues(t *testing.T) { + // Arrange + setting.UI.IssuePagingNum = 1 + assert.NoError(t, models.LoadFixtures()) + + ctx := test.MockContext(t, "issues") + test.LoadUser(t, ctx, 30) + ctx.Req.Form.Set("state", "open") + + // Assume: User 30 has access to two Repos with Issues, one of the Repos being archived. + repos, _, _ := models.GetUserRepositories(&models.SearchRepoOptions{Actor: ctx.User}) + assert.Len(t, repos, 2) + IsArchived := make(map[int64]bool) + NumIssues := make(map[int64]int) + for _, repo := range repos { + IsArchived[repo.ID] = repo.IsArchived + NumIssues[repo.ID] = repo.NumIssues + } + assert.False(t, IsArchived[50]) + assert.EqualValues(t, 1, NumIssues[50]) + assert.True(t, IsArchived[51]) + assert.EqualValues(t, 1, NumIssues[51]) + + // Act + Issues(ctx) + + // Assert: One Issue (ID 30) from one Repo (ID 50) is retrieved, while nothing from archived Repo 51 is retrieved + assert.EqualValues(t, http.StatusOK, ctx.Resp.Status()) + + assert.EqualValues(t, map[int64]int64{50: 1}, ctx.Data["Counts"]) + assert.Len(t, ctx.Data["Issues"], 1) + assert.Len(t, ctx.Data["Repos"], 1) +} + +func TestIssues(t *testing.T) { + setting.UI.IssuePagingNum = 1 + assert.NoError(t, models.LoadFixtures()) + + ctx := test.MockContext(t, "issues") + test.LoadUser(t, ctx, 2) + ctx.Req.Form.Set("state", "closed") + Issues(ctx) + assert.EqualValues(t, http.StatusOK, ctx.Resp.Status()) + + assert.EqualValues(t, map[int64]int64{1: 1, 2: 1}, ctx.Data["Counts"]) + assert.EqualValues(t, true, ctx.Data["IsShowClosed"]) + assert.Len(t, ctx.Data["Issues"], 1) + assert.Len(t, ctx.Data["Repos"], 2) +} + +func TestPulls(t *testing.T) { + setting.UI.IssuePagingNum = 20 + assert.NoError(t, models.LoadFixtures()) + + ctx := test.MockContext(t, "pulls") + test.LoadUser(t, ctx, 2) + ctx.Req.Form.Set("state", "open") + Pulls(ctx) + assert.EqualValues(t, http.StatusOK, ctx.Resp.Status()) + + assert.Len(t, ctx.Data["Issues"], 3) +} + +func TestMilestones(t *testing.T) { + setting.UI.IssuePagingNum = 1 + assert.NoError(t, models.LoadFixtures()) + + ctx := test.MockContext(t, "milestones") + test.LoadUser(t, ctx, 2) + ctx.SetParams("sort", "issues") + ctx.Req.Form.Set("state", "closed") + ctx.Req.Form.Set("sort", "furthestduedate") + Milestones(ctx) + assert.EqualValues(t, http.StatusOK, ctx.Resp.Status()) + assert.EqualValues(t, map[int64]int64{1: 1}, ctx.Data["Counts"]) + assert.EqualValues(t, true, ctx.Data["IsShowClosed"]) + assert.EqualValues(t, "furthestduedate", ctx.Data["SortType"]) + assert.EqualValues(t, 1, ctx.Data["Total"]) + assert.Len(t, ctx.Data["Milestones"], 1) + assert.Len(t, ctx.Data["Repos"], 2) // both repo 42 and 1 have milestones and both are owned by user 2 +} + +func TestMilestonesForSpecificRepo(t *testing.T) { + setting.UI.IssuePagingNum = 1 + assert.NoError(t, models.LoadFixtures()) + + ctx := test.MockContext(t, "milestones") + test.LoadUser(t, ctx, 2) + ctx.SetParams("sort", "issues") + ctx.SetParams("repo", "1") + ctx.Req.Form.Set("state", "closed") + ctx.Req.Form.Set("sort", "furthestduedate") + Milestones(ctx) + assert.EqualValues(t, http.StatusOK, ctx.Resp.Status()) + assert.EqualValues(t, map[int64]int64{1: 1}, ctx.Data["Counts"]) + assert.EqualValues(t, true, ctx.Data["IsShowClosed"]) + assert.EqualValues(t, "furthestduedate", ctx.Data["SortType"]) + assert.EqualValues(t, 1, ctx.Data["Total"]) + assert.Len(t, ctx.Data["Milestones"], 1) + assert.Len(t, ctx.Data["Repos"], 2) // both repo 42 and 1 have milestones and both are owned by user 2 +} diff --git a/routers/web/user/main_test.go b/routers/web/user/main_test.go new file mode 100644 index 0000000000..be17dd1f31 --- /dev/null +++ b/routers/web/user/main_test.go @@ -0,0 +1,16 @@ +// 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 ( + "path/filepath" + "testing" + + "code.gitea.io/gitea/models" +) + +func TestMain(m *testing.M) { + models.MainTest(m, filepath.Join("..", "..", "..")) +} diff --git a/routers/web/user/notification.go b/routers/web/user/notification.go new file mode 100644 index 0000000000..523e945db9 --- /dev/null +++ b/routers/web/user/notification.go @@ -0,0 +1,192 @@ +// Copyright 2019 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 ( + "errors" + "fmt" + "net/http" + "strconv" + "strings" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/setting" +) + +const ( + tplNotification base.TplName = "user/notification/notification" + tplNotificationDiv base.TplName = "user/notification/notification_div" +) + +// GetNotificationCount is the middleware that sets the notification count in the context +func GetNotificationCount(c *context.Context) { + if strings.HasPrefix(c.Req.URL.Path, "/api") { + return + } + + if !c.IsSigned { + return + } + + c.Data["NotificationUnreadCount"] = func() int64 { + count, err := models.GetNotificationCount(c.User, models.NotificationStatusUnread) + if err != nil { + c.ServerError("GetNotificationCount", err) + return -1 + } + + return count + } +} + +// Notifications is the notifications page +func Notifications(c *context.Context) { + getNotifications(c) + if c.Written() { + return + } + if c.QueryBool("div-only") { + c.HTML(http.StatusOK, tplNotificationDiv) + return + } + c.HTML(http.StatusOK, tplNotification) +} + +func getNotifications(c *context.Context) { + var ( + keyword = strings.Trim(c.Query("q"), " ") + status models.NotificationStatus + page = c.QueryInt("page") + perPage = c.QueryInt("perPage") + ) + if page < 1 { + page = 1 + } + if perPage < 1 { + perPage = 20 + } + + switch keyword { + case "read": + status = models.NotificationStatusRead + default: + status = models.NotificationStatusUnread + } + + total, err := models.GetNotificationCount(c.User, status) + if err != nil { + c.ServerError("ErrGetNotificationCount", err) + return + } + + // redirect to last page if request page is more than total pages + pager := context.NewPagination(int(total), perPage, page, 5) + if pager.Paginater.Current() < page { + c.Redirect(fmt.Sprintf("/notifications?q=%s&page=%d", c.Query("q"), pager.Paginater.Current())) + return + } + + statuses := []models.NotificationStatus{status, models.NotificationStatusPinned} + notifications, err := models.NotificationsForUser(c.User, statuses, page, perPage) + if err != nil { + c.ServerError("ErrNotificationsForUser", err) + return + } + + failCount := 0 + + repos, failures, err := notifications.LoadRepos() + if err != nil { + c.ServerError("LoadRepos", err) + return + } + notifications = notifications.Without(failures) + if err := repos.LoadAttributes(); err != nil { + c.ServerError("LoadAttributes", err) + return + } + failCount += len(failures) + + failures, err = notifications.LoadIssues() + if err != nil { + c.ServerError("LoadIssues", err) + return + } + notifications = notifications.Without(failures) + failCount += len(failures) + + failures, err = notifications.LoadComments() + if err != nil { + c.ServerError("LoadComments", err) + return + } + notifications = notifications.Without(failures) + failCount += len(failures) + + if failCount > 0 { + c.Flash.Error(fmt.Sprintf("ERROR: %d notifications were removed due to missing parts - check the logs", failCount)) + } + + c.Data["Title"] = c.Tr("notifications") + c.Data["Keyword"] = keyword + c.Data["Status"] = status + c.Data["Notifications"] = notifications + + pager.SetDefaultParams(c) + c.Data["Page"] = pager +} + +// NotificationStatusPost is a route for changing the status of a notification +func NotificationStatusPost(c *context.Context) { + var ( + notificationID, _ = strconv.ParseInt(c.Req.PostFormValue("notification_id"), 10, 64) + statusStr = c.Req.PostFormValue("status") + status models.NotificationStatus + ) + + switch statusStr { + case "read": + status = models.NotificationStatusRead + case "unread": + status = models.NotificationStatusUnread + case "pinned": + status = models.NotificationStatusPinned + default: + c.ServerError("InvalidNotificationStatus", errors.New("Invalid notification status")) + return + } + + if err := models.SetNotificationStatus(notificationID, c.User, status); err != nil { + c.ServerError("SetNotificationStatus", err) + return + } + + if !c.QueryBool("noredirect") { + url := fmt.Sprintf("%s/notifications?page=%s", setting.AppSubURL, c.Query("page")) + c.Redirect(url, http.StatusSeeOther) + } + + getNotifications(c) + if c.Written() { + return + } + c.Data["Link"] = setting.AppURL + "notifications" + + c.HTML(http.StatusOK, tplNotificationDiv) +} + +// NotificationPurgePost is a route for 'purging' the list of notifications - marking all unread as read +func NotificationPurgePost(c *context.Context) { + err := models.UpdateNotificationStatuses(c.User, models.NotificationStatusUnread, models.NotificationStatusRead) + if err != nil { + c.ServerError("ErrUpdateNotificationStatuses", err) + return + } + + url := fmt.Sprintf("%s/notifications", setting.AppSubURL) + c.Redirect(url, http.StatusSeeOther) +} diff --git a/routers/web/user/oauth.go b/routers/web/user/oauth.go new file mode 100644 index 0000000000..3ef5a56c01 --- /dev/null +++ b/routers/web/user/oauth.go @@ -0,0 +1,646 @@ +// Copyright 2019 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 ( + "encoding/base64" + "fmt" + "html" + "net/http" + "net/url" + "strings" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/auth/sso" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/timeutil" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/forms" + + "gitea.com/go-chi/binding" + "github.com/dgrijalva/jwt-go" +) + +const ( + tplGrantAccess base.TplName = "user/auth/grant" + tplGrantError base.TplName = "user/auth/grant_error" +) + +// TODO move error and responses to SDK or models + +// AuthorizeErrorCode represents an error code specified in RFC 6749 +type AuthorizeErrorCode string + +const ( + // ErrorCodeInvalidRequest represents the according error in RFC 6749 + ErrorCodeInvalidRequest AuthorizeErrorCode = "invalid_request" + // ErrorCodeUnauthorizedClient represents the according error in RFC 6749 + ErrorCodeUnauthorizedClient AuthorizeErrorCode = "unauthorized_client" + // ErrorCodeAccessDenied represents the according error in RFC 6749 + ErrorCodeAccessDenied AuthorizeErrorCode = "access_denied" + // ErrorCodeUnsupportedResponseType represents the according error in RFC 6749 + ErrorCodeUnsupportedResponseType AuthorizeErrorCode = "unsupported_response_type" + // ErrorCodeInvalidScope represents the according error in RFC 6749 + ErrorCodeInvalidScope AuthorizeErrorCode = "invalid_scope" + // ErrorCodeServerError represents the according error in RFC 6749 + ErrorCodeServerError AuthorizeErrorCode = "server_error" + // ErrorCodeTemporaryUnavailable represents the according error in RFC 6749 + ErrorCodeTemporaryUnavailable AuthorizeErrorCode = "temporarily_unavailable" +) + +// AuthorizeError represents an error type specified in RFC 6749 +type AuthorizeError struct { + ErrorCode AuthorizeErrorCode `json:"error" form:"error"` + ErrorDescription string + State string +} + +// Error returns the error message +func (err AuthorizeError) Error() string { + return fmt.Sprintf("%s: %s", err.ErrorCode, err.ErrorDescription) +} + +// AccessTokenErrorCode represents an error code specified in RFC 6749 +type AccessTokenErrorCode string + +const ( + // AccessTokenErrorCodeInvalidRequest represents an error code specified in RFC 6749 + AccessTokenErrorCodeInvalidRequest AccessTokenErrorCode = "invalid_request" + // AccessTokenErrorCodeInvalidClient represents an error code specified in RFC 6749 + AccessTokenErrorCodeInvalidClient = "invalid_client" + // AccessTokenErrorCodeInvalidGrant represents an error code specified in RFC 6749 + AccessTokenErrorCodeInvalidGrant = "invalid_grant" + // AccessTokenErrorCodeUnauthorizedClient represents an error code specified in RFC 6749 + AccessTokenErrorCodeUnauthorizedClient = "unauthorized_client" + // AccessTokenErrorCodeUnsupportedGrantType represents an error code specified in RFC 6749 + AccessTokenErrorCodeUnsupportedGrantType = "unsupported_grant_type" + // AccessTokenErrorCodeInvalidScope represents an error code specified in RFC 6749 + AccessTokenErrorCodeInvalidScope = "invalid_scope" +) + +// AccessTokenError represents an error response specified in RFC 6749 +type AccessTokenError struct { + ErrorCode AccessTokenErrorCode `json:"error" form:"error"` + ErrorDescription string `json:"error_description"` +} + +// Error returns the error message +func (err AccessTokenError) Error() string { + return fmt.Sprintf("%s: %s", err.ErrorCode, err.ErrorDescription) +} + +// BearerTokenErrorCode represents an error code specified in RFC 6750 +type BearerTokenErrorCode string + +const ( + // BearerTokenErrorCodeInvalidRequest represents an error code specified in RFC 6750 + BearerTokenErrorCodeInvalidRequest BearerTokenErrorCode = "invalid_request" + // BearerTokenErrorCodeInvalidToken represents an error code specified in RFC 6750 + BearerTokenErrorCodeInvalidToken BearerTokenErrorCode = "invalid_token" + // BearerTokenErrorCodeInsufficientScope represents an error code specified in RFC 6750 + BearerTokenErrorCodeInsufficientScope BearerTokenErrorCode = "insufficient_scope" +) + +// BearerTokenError represents an error response specified in RFC 6750 +type BearerTokenError struct { + ErrorCode BearerTokenErrorCode `json:"error" form:"error"` + ErrorDescription string `json:"error_description"` +} + +// TokenType specifies the kind of token +type TokenType string + +const ( + // TokenTypeBearer represents a token type specified in RFC 6749 + TokenTypeBearer TokenType = "bearer" + // TokenTypeMAC represents a token type specified in RFC 6749 + TokenTypeMAC = "mac" +) + +// AccessTokenResponse represents a successful access token response +type AccessTokenResponse struct { + AccessToken string `json:"access_token"` + TokenType TokenType `json:"token_type"` + ExpiresIn int64 `json:"expires_in"` + RefreshToken string `json:"refresh_token"` + IDToken string `json:"id_token,omitempty"` +} + +func newAccessTokenResponse(grant *models.OAuth2Grant, clientSecret string) (*AccessTokenResponse, *AccessTokenError) { + if setting.OAuth2.InvalidateRefreshTokens { + if err := grant.IncreaseCounter(); err != nil { + return nil, &AccessTokenError{ + ErrorCode: AccessTokenErrorCodeInvalidGrant, + ErrorDescription: "cannot increase the grant counter", + } + } + } + // generate access token to access the API + expirationDate := timeutil.TimeStampNow().Add(setting.OAuth2.AccessTokenExpirationTime) + accessToken := &models.OAuth2Token{ + GrantID: grant.ID, + Type: models.TypeAccessToken, + StandardClaims: jwt.StandardClaims{ + ExpiresAt: expirationDate.AsTime().Unix(), + }, + } + signedAccessToken, err := accessToken.SignToken() + if err != nil { + return nil, &AccessTokenError{ + ErrorCode: AccessTokenErrorCodeInvalidRequest, + ErrorDescription: "cannot sign token", + } + } + + // generate refresh token to request an access token after it expired later + refreshExpirationDate := timeutil.TimeStampNow().Add(setting.OAuth2.RefreshTokenExpirationTime * 60 * 60).AsTime().Unix() + refreshToken := &models.OAuth2Token{ + GrantID: grant.ID, + Counter: grant.Counter, + Type: models.TypeRefreshToken, + StandardClaims: jwt.StandardClaims{ + ExpiresAt: refreshExpirationDate, + }, + } + signedRefreshToken, err := refreshToken.SignToken() + if err != nil { + return nil, &AccessTokenError{ + ErrorCode: AccessTokenErrorCodeInvalidRequest, + ErrorDescription: "cannot sign token", + } + } + + // generate OpenID Connect id_token + signedIDToken := "" + if grant.ScopeContains("openid") { + app, err := models.GetOAuth2ApplicationByID(grant.ApplicationID) + if err != nil { + return nil, &AccessTokenError{ + ErrorCode: AccessTokenErrorCodeInvalidRequest, + ErrorDescription: "cannot find application", + } + } + idToken := &models.OIDCToken{ + StandardClaims: jwt.StandardClaims{ + ExpiresAt: expirationDate.AsTime().Unix(), + Issuer: setting.AppURL, + Audience: app.ClientID, + Subject: fmt.Sprint(grant.UserID), + }, + Nonce: grant.Nonce, + } + signedIDToken, err = idToken.SignToken(clientSecret) + if err != nil { + return nil, &AccessTokenError{ + ErrorCode: AccessTokenErrorCodeInvalidRequest, + ErrorDescription: "cannot sign token", + } + } + } + + return &AccessTokenResponse{ + AccessToken: signedAccessToken, + TokenType: TokenTypeBearer, + ExpiresIn: setting.OAuth2.AccessTokenExpirationTime, + RefreshToken: signedRefreshToken, + IDToken: signedIDToken, + }, nil +} + +type userInfoResponse struct { + Sub string `json:"sub"` + Name string `json:"name"` + Username string `json:"preferred_username"` + Email string `json:"email"` + Picture string `json:"picture"` +} + +// InfoOAuth manages request for userinfo endpoint +func InfoOAuth(ctx *context.Context) { + header := ctx.Req.Header.Get("Authorization") + auths := strings.Fields(header) + if len(auths) != 2 || auths[0] != "Bearer" { + ctx.HandleText(http.StatusUnauthorized, "no valid auth token authorization") + return + } + uid := sso.CheckOAuthAccessToken(auths[1]) + if uid == 0 { + handleBearerTokenError(ctx, BearerTokenError{ + ErrorCode: BearerTokenErrorCodeInvalidToken, + ErrorDescription: "Access token not assigned to any user", + }) + return + } + authUser, err := models.GetUserByID(uid) + if err != nil { + ctx.ServerError("GetUserByID", err) + return + } + response := &userInfoResponse{ + Sub: fmt.Sprint(authUser.ID), + Name: authUser.FullName, + Username: authUser.Name, + Email: authUser.Email, + Picture: authUser.AvatarLink(), + } + ctx.JSON(http.StatusOK, response) +} + +// AuthorizeOAuth manages authorize requests +func AuthorizeOAuth(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.AuthorizationForm) + errs := binding.Errors{} + errs = form.Validate(ctx.Req, errs) + if len(errs) > 0 { + errstring := "" + for _, e := range errs { + errstring += e.Error() + "\n" + } + ctx.ServerError("AuthorizeOAuth: Validate: ", fmt.Errorf("errors occurred during validation: %s", errstring)) + return + } + + app, err := models.GetOAuth2ApplicationByClientID(form.ClientID) + if err != nil { + if models.IsErrOauthClientIDInvalid(err) { + handleAuthorizeError(ctx, AuthorizeError{ + ErrorCode: ErrorCodeUnauthorizedClient, + ErrorDescription: "Client ID not registered", + State: form.State, + }, "") + return + } + ctx.ServerError("GetOAuth2ApplicationByClientID", err) + return + } + if err := app.LoadUser(); err != nil { + ctx.ServerError("LoadUser", err) + return + } + + if !app.ContainsRedirectURI(form.RedirectURI) { + handleAuthorizeError(ctx, AuthorizeError{ + ErrorCode: ErrorCodeInvalidRequest, + ErrorDescription: "Unregistered Redirect URI", + State: form.State, + }, "") + return + } + + if form.ResponseType != "code" { + handleAuthorizeError(ctx, AuthorizeError{ + ErrorCode: ErrorCodeUnsupportedResponseType, + ErrorDescription: "Only code response type is supported.", + State: form.State, + }, form.RedirectURI) + return + } + + // pkce support + switch form.CodeChallengeMethod { + case "S256": + case "plain": + if err := ctx.Session.Set("CodeChallengeMethod", form.CodeChallengeMethod); err != nil { + handleAuthorizeError(ctx, AuthorizeError{ + ErrorCode: ErrorCodeServerError, + ErrorDescription: "cannot set code challenge method", + State: form.State, + }, form.RedirectURI) + return + } + if err := ctx.Session.Set("CodeChallengeMethod", form.CodeChallenge); err != nil { + handleAuthorizeError(ctx, AuthorizeError{ + ErrorCode: ErrorCodeServerError, + ErrorDescription: "cannot set code challenge", + State: form.State, + }, form.RedirectURI) + return + } + // Here we're just going to try to release the session early + if err := ctx.Session.Release(); err != nil { + // we'll tolerate errors here as they *should* get saved elsewhere + log.Error("Unable to save changes to the session: %v", err) + } + case "": + break + default: + handleAuthorizeError(ctx, AuthorizeError{ + ErrorCode: ErrorCodeInvalidRequest, + ErrorDescription: "unsupported code challenge method", + State: form.State, + }, form.RedirectURI) + return + } + + grant, err := app.GetGrantByUserID(ctx.User.ID) + if err != nil { + handleServerError(ctx, form.State, form.RedirectURI) + return + } + + // Redirect if user already granted access + if grant != nil { + code, err := grant.GenerateNewAuthorizationCode(form.RedirectURI, form.CodeChallenge, form.CodeChallengeMethod) + if err != nil { + handleServerError(ctx, form.State, form.RedirectURI) + return + } + redirect, err := code.GenerateRedirectURI(form.State) + if err != nil { + handleServerError(ctx, form.State, form.RedirectURI) + return + } + // Update nonce to reflect the new session + if len(form.Nonce) > 0 { + err := grant.SetNonce(form.Nonce) + if err != nil { + log.Error("Unable to update nonce: %v", err) + } + } + ctx.Redirect(redirect.String(), 302) + return + } + + // show authorize page to grant access + ctx.Data["Application"] = app + ctx.Data["RedirectURI"] = form.RedirectURI + ctx.Data["State"] = form.State + ctx.Data["Scope"] = form.Scope + ctx.Data["Nonce"] = form.Nonce + ctx.Data["ApplicationUserLink"] = "<a href=\"" + html.EscapeString(setting.AppURL) + html.EscapeString(url.PathEscape(app.User.LowerName)) + "\">@" + html.EscapeString(app.User.Name) + "</a>" + ctx.Data["ApplicationRedirectDomainHTML"] = "<strong>" + html.EscapeString(form.RedirectURI) + "</strong>" + // TODO document SESSION <=> FORM + err = ctx.Session.Set("client_id", app.ClientID) + if err != nil { + handleServerError(ctx, form.State, form.RedirectURI) + log.Error(err.Error()) + return + } + err = ctx.Session.Set("redirect_uri", form.RedirectURI) + if err != nil { + handleServerError(ctx, form.State, form.RedirectURI) + log.Error(err.Error()) + return + } + err = ctx.Session.Set("state", form.State) + if err != nil { + handleServerError(ctx, form.State, form.RedirectURI) + log.Error(err.Error()) + return + } + // Here we're just going to try to release the session early + if err := ctx.Session.Release(); err != nil { + // we'll tolerate errors here as they *should* get saved elsewhere + log.Error("Unable to save changes to the session: %v", err) + } + ctx.HTML(http.StatusOK, tplGrantAccess) +} + +// GrantApplicationOAuth manages the post request submitted when a user grants access to an application +func GrantApplicationOAuth(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.GrantApplicationForm) + if ctx.Session.Get("client_id") != form.ClientID || ctx.Session.Get("state") != form.State || + ctx.Session.Get("redirect_uri") != form.RedirectURI { + ctx.Error(http.StatusBadRequest) + return + } + app, err := models.GetOAuth2ApplicationByClientID(form.ClientID) + if err != nil { + ctx.ServerError("GetOAuth2ApplicationByClientID", err) + return + } + grant, err := app.CreateGrant(ctx.User.ID, form.Scope) + if err != nil { + handleAuthorizeError(ctx, AuthorizeError{ + State: form.State, + ErrorDescription: "cannot create grant for user", + ErrorCode: ErrorCodeServerError, + }, form.RedirectURI) + return + } + if len(form.Nonce) > 0 { + err := grant.SetNonce(form.Nonce) + if err != nil { + log.Error("Unable to update nonce: %v", err) + } + } + + var codeChallenge, codeChallengeMethod string + codeChallenge, _ = ctx.Session.Get("CodeChallenge").(string) + codeChallengeMethod, _ = ctx.Session.Get("CodeChallengeMethod").(string) + + code, err := grant.GenerateNewAuthorizationCode(form.RedirectURI, codeChallenge, codeChallengeMethod) + if err != nil { + handleServerError(ctx, form.State, form.RedirectURI) + return + } + redirect, err := code.GenerateRedirectURI(form.State) + if err != nil { + handleServerError(ctx, form.State, form.RedirectURI) + return + } + ctx.Redirect(redirect.String(), 302) +} + +// OIDCWellKnown generates JSON so OIDC clients know Gitea's capabilities +func OIDCWellKnown(ctx *context.Context) { + t := ctx.Render.TemplateLookup("user/auth/oidc_wellknown") + ctx.Resp.Header().Set("Content-Type", "application/json") + if err := t.Execute(ctx.Resp, ctx.Data); err != nil { + log.Error("%v", err) + ctx.Error(http.StatusInternalServerError) + } +} + +// AccessTokenOAuth manages all access token requests by the client +func AccessTokenOAuth(ctx *context.Context) { + form := *web.GetForm(ctx).(*forms.AccessTokenForm) + if form.ClientID == "" { + authHeader := ctx.Req.Header.Get("Authorization") + authContent := strings.SplitN(authHeader, " ", 2) + if len(authContent) == 2 && authContent[0] == "Basic" { + payload, err := base64.StdEncoding.DecodeString(authContent[1]) + if err != nil { + handleAccessTokenError(ctx, AccessTokenError{ + ErrorCode: AccessTokenErrorCodeInvalidRequest, + ErrorDescription: "cannot parse basic auth header", + }) + return + } + pair := strings.SplitN(string(payload), ":", 2) + if len(pair) != 2 { + handleAccessTokenError(ctx, AccessTokenError{ + ErrorCode: AccessTokenErrorCodeInvalidRequest, + ErrorDescription: "cannot parse basic auth header", + }) + return + } + form.ClientID = pair[0] + form.ClientSecret = pair[1] + } + } + switch form.GrantType { + case "refresh_token": + handleRefreshToken(ctx, form) + return + case "authorization_code": + handleAuthorizationCode(ctx, form) + return + default: + handleAccessTokenError(ctx, AccessTokenError{ + ErrorCode: AccessTokenErrorCodeUnsupportedGrantType, + ErrorDescription: "Only refresh_token or authorization_code grant type is supported", + }) + } +} + +func handleRefreshToken(ctx *context.Context, form forms.AccessTokenForm) { + token, err := models.ParseOAuth2Token(form.RefreshToken) + if err != nil { + handleAccessTokenError(ctx, AccessTokenError{ + ErrorCode: AccessTokenErrorCodeUnauthorizedClient, + ErrorDescription: "client is not authorized", + }) + return + } + // get grant before increasing counter + grant, err := models.GetOAuth2GrantByID(token.GrantID) + if err != nil || grant == nil { + handleAccessTokenError(ctx, AccessTokenError{ + ErrorCode: AccessTokenErrorCodeInvalidGrant, + ErrorDescription: "grant does not exist", + }) + return + } + + // check if token got already used + if setting.OAuth2.InvalidateRefreshTokens && (grant.Counter != token.Counter || token.Counter == 0) { + handleAccessTokenError(ctx, AccessTokenError{ + ErrorCode: AccessTokenErrorCodeUnauthorizedClient, + ErrorDescription: "token was already used", + }) + log.Warn("A client tried to use a refresh token for grant_id = %d was used twice!", grant.ID) + return + } + accessToken, tokenErr := newAccessTokenResponse(grant, form.ClientSecret) + if tokenErr != nil { + handleAccessTokenError(ctx, *tokenErr) + return + } + ctx.JSON(http.StatusOK, accessToken) +} + +func handleAuthorizationCode(ctx *context.Context, form forms.AccessTokenForm) { + app, err := models.GetOAuth2ApplicationByClientID(form.ClientID) + if err != nil { + handleAccessTokenError(ctx, AccessTokenError{ + ErrorCode: AccessTokenErrorCodeInvalidClient, + ErrorDescription: fmt.Sprintf("cannot load client with client id: '%s'", form.ClientID), + }) + return + } + if !app.ValidateClientSecret([]byte(form.ClientSecret)) { + handleAccessTokenError(ctx, AccessTokenError{ + ErrorCode: AccessTokenErrorCodeUnauthorizedClient, + ErrorDescription: "client is not authorized", + }) + return + } + if form.RedirectURI != "" && !app.ContainsRedirectURI(form.RedirectURI) { + handleAccessTokenError(ctx, AccessTokenError{ + ErrorCode: AccessTokenErrorCodeUnauthorizedClient, + ErrorDescription: "client is not authorized", + }) + return + } + authorizationCode, err := models.GetOAuth2AuthorizationByCode(form.Code) + if err != nil || authorizationCode == nil { + handleAccessTokenError(ctx, AccessTokenError{ + ErrorCode: AccessTokenErrorCodeUnauthorizedClient, + ErrorDescription: "client is not authorized", + }) + return + } + // check if code verifier authorizes the client, PKCE support + if !authorizationCode.ValidateCodeChallenge(form.CodeVerifier) { + handleAccessTokenError(ctx, AccessTokenError{ + ErrorCode: AccessTokenErrorCodeUnauthorizedClient, + ErrorDescription: "client is not authorized", + }) + return + } + // check if granted for this application + if authorizationCode.Grant.ApplicationID != app.ID { + handleAccessTokenError(ctx, AccessTokenError{ + ErrorCode: AccessTokenErrorCodeInvalidGrant, + ErrorDescription: "invalid grant", + }) + return + } + // remove token from database to deny duplicate usage + if err := authorizationCode.Invalidate(); err != nil { + handleAccessTokenError(ctx, AccessTokenError{ + ErrorCode: AccessTokenErrorCodeInvalidRequest, + ErrorDescription: "cannot proceed your request", + }) + } + resp, tokenErr := newAccessTokenResponse(authorizationCode.Grant, form.ClientSecret) + if tokenErr != nil { + handleAccessTokenError(ctx, *tokenErr) + return + } + // send successful response + ctx.JSON(http.StatusOK, resp) +} + +func handleAccessTokenError(ctx *context.Context, acErr AccessTokenError) { + ctx.JSON(http.StatusBadRequest, acErr) +} + +func handleServerError(ctx *context.Context, state string, redirectURI string) { + handleAuthorizeError(ctx, AuthorizeError{ + ErrorCode: ErrorCodeServerError, + ErrorDescription: "A server error occurred", + State: state, + }, redirectURI) +} + +func handleAuthorizeError(ctx *context.Context, authErr AuthorizeError, redirectURI string) { + if redirectURI == "" { + log.Warn("Authorization failed: %v", authErr.ErrorDescription) + ctx.Data["Error"] = authErr + ctx.HTML(400, tplGrantError) + return + } + redirect, err := url.Parse(redirectURI) + if err != nil { + ctx.ServerError("url.Parse", err) + return + } + q := redirect.Query() + q.Set("error", string(authErr.ErrorCode)) + q.Set("error_description", authErr.ErrorDescription) + q.Set("state", authErr.State) + redirect.RawQuery = q.Encode() + ctx.Redirect(redirect.String(), 302) +} + +func handleBearerTokenError(ctx *context.Context, beErr BearerTokenError) { + ctx.Resp.Header().Set("WWW-Authenticate", fmt.Sprintf("Bearer realm=\"\", error=\"%s\", error_description=\"%s\"", beErr.ErrorCode, beErr.ErrorDescription)) + switch beErr.ErrorCode { + case BearerTokenErrorCodeInvalidRequest: + ctx.JSON(http.StatusBadRequest, beErr) + case BearerTokenErrorCodeInvalidToken: + ctx.JSON(http.StatusUnauthorized, beErr) + case BearerTokenErrorCodeInsufficientScope: + ctx.JSON(http.StatusForbidden, beErr) + default: + log.Error("Invalid BearerTokenErrorCode: %v", beErr.ErrorCode) + ctx.ServerError("Unhandled BearerTokenError", fmt.Errorf("BearerTokenError: error=\"%v\", error_description=\"%v\"", beErr.ErrorCode, beErr.ErrorDescription)) + } +} diff --git a/routers/web/user/profile.go b/routers/web/user/profile.go new file mode 100644 index 0000000000..e66820e131 --- /dev/null +++ b/routers/web/user/profile.go @@ -0,0 +1,329 @@ +// Copyright 2015 The Gogs Authors. All rights reserved. +// Copyright 2019 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/http" + "path" + "strings" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/markup" + "code.gitea.io/gitea/modules/markup/markdown" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/routers/web/org" +) + +// GetUserByName get user by name +func GetUserByName(ctx *context.Context, name string) *models.User { + user, err := models.GetUserByName(name) + if err != nil { + if models.IsErrUserNotExist(err) { + if redirectUserID, err := models.LookupUserRedirect(name); err == nil { + context.RedirectToUser(ctx, name, redirectUserID) + } else { + ctx.NotFound("GetUserByName", err) + } + } else { + ctx.ServerError("GetUserByName", err) + } + return nil + } + return user +} + +// GetUserByParams returns user whose name is presented in URL paramenter. +func GetUserByParams(ctx *context.Context) *models.User { + return GetUserByName(ctx, ctx.Params(":username")) +} + +// Profile render user's profile page +func Profile(ctx *context.Context) { + uname := ctx.Params(":username") + + // Special handle for FireFox requests favicon.ico. + if uname == "favicon.ico" { + ctx.ServeFile(path.Join(setting.StaticRootPath, "public/img/favicon.png")) + return + } + + if strings.HasSuffix(uname, ".png") { + ctx.Error(http.StatusNotFound) + return + } + + isShowKeys := false + if strings.HasSuffix(uname, ".keys") { + isShowKeys = true + uname = strings.TrimSuffix(uname, ".keys") + } + + isShowGPG := false + if strings.HasSuffix(uname, ".gpg") { + isShowGPG = true + uname = strings.TrimSuffix(uname, ".gpg") + } + + ctxUser := GetUserByName(ctx, uname) + if ctx.Written() { + return + } + + // Show SSH keys. + if isShowKeys { + ShowSSHKeys(ctx, ctxUser.ID) + return + } + + // Show GPG keys. + if isShowGPG { + ShowGPGKeys(ctx, ctxUser.ID) + return + } + + if ctxUser.IsOrganization() { + org.Home(ctx) + return + } + + // Show OpenID URIs + openIDs, err := models.GetUserOpenIDs(ctxUser.ID) + if err != nil { + ctx.ServerError("GetUserOpenIDs", err) + return + } + + ctx.Data["Title"] = ctxUser.DisplayName() + ctx.Data["PageIsUserProfile"] = true + ctx.Data["Owner"] = ctxUser + ctx.Data["OpenIDs"] = openIDs + + if setting.Service.EnableUserHeatmap { + data, err := models.GetUserHeatmapDataByUser(ctxUser, ctx.User) + if err != nil { + ctx.ServerError("GetUserHeatmapDataByUser", err) + return + } + ctx.Data["HeatmapData"] = data + } + + if len(ctxUser.Description) != 0 { + content, err := markdown.RenderString(&markup.RenderContext{ + URLPrefix: ctx.Repo.RepoLink, + Metas: map[string]string{"mode": "document"}, + }, ctxUser.Description) + if err != nil { + ctx.ServerError("RenderString", err) + return + } + ctx.Data["RenderedDescription"] = content + } + + showPrivate := ctx.IsSigned && (ctx.User.IsAdmin || ctx.User.ID == ctxUser.ID) + + orgs, err := models.GetOrgsByUserID(ctxUser.ID, showPrivate) + if err != nil { + ctx.ServerError("GetOrgsByUserIDDesc", err) + return + } + + ctx.Data["Orgs"] = orgs + ctx.Data["HasOrgsVisible"] = models.HasOrgsVisible(orgs, ctx.User) + + tab := ctx.Query("tab") + ctx.Data["TabName"] = tab + + page := ctx.QueryInt("page") + if page <= 0 { + page = 1 + } + + topicOnly := ctx.QueryBool("topic") + + var ( + repos []*models.Repository + count int64 + total int + orderBy models.SearchOrderBy + ) + + ctx.Data["SortType"] = ctx.Query("sort") + switch ctx.Query("sort") { + case "newest": + orderBy = models.SearchOrderByNewest + case "oldest": + orderBy = models.SearchOrderByOldest + case "recentupdate": + orderBy = models.SearchOrderByRecentUpdated + case "leastupdate": + orderBy = models.SearchOrderByLeastUpdated + case "reversealphabetically": + orderBy = models.SearchOrderByAlphabeticallyReverse + case "alphabetically": + orderBy = models.SearchOrderByAlphabetically + case "moststars": + orderBy = models.SearchOrderByStarsReverse + case "feweststars": + orderBy = models.SearchOrderByStars + case "mostforks": + orderBy = models.SearchOrderByForksReverse + case "fewestforks": + orderBy = models.SearchOrderByForks + default: + ctx.Data["SortType"] = "recentupdate" + orderBy = models.SearchOrderByRecentUpdated + } + + keyword := strings.Trim(ctx.Query("q"), " ") + ctx.Data["Keyword"] = keyword + switch tab { + case "followers": + items, err := ctxUser.GetFollowers(models.ListOptions{ + PageSize: setting.UI.User.RepoPagingNum, + Page: page, + }) + if err != nil { + ctx.ServerError("GetFollowers", err) + return + } + ctx.Data["Cards"] = items + + total = ctxUser.NumFollowers + case "following": + items, err := ctxUser.GetFollowing(models.ListOptions{ + PageSize: setting.UI.User.RepoPagingNum, + Page: page, + }) + if err != nil { + ctx.ServerError("GetFollowing", err) + return + } + ctx.Data["Cards"] = items + + total = ctxUser.NumFollowing + case "activity": + retrieveFeeds(ctx, models.GetFeedsOptions{RequestedUser: ctxUser, + Actor: ctx.User, + IncludePrivate: showPrivate, + OnlyPerformedBy: true, + IncludeDeleted: false, + Date: ctx.Query("date"), + }) + if ctx.Written() { + return + } + case "stars": + ctx.Data["PageIsProfileStarList"] = true + repos, count, err = models.SearchRepository(&models.SearchRepoOptions{ + ListOptions: models.ListOptions{ + PageSize: setting.UI.User.RepoPagingNum, + Page: page, + }, + Actor: ctx.User, + Keyword: keyword, + OrderBy: orderBy, + Private: ctx.IsSigned, + StarredByID: ctxUser.ID, + Collaborate: util.OptionalBoolFalse, + TopicOnly: topicOnly, + IncludeDescription: setting.UI.SearchRepoDescription, + }) + if err != nil { + ctx.ServerError("SearchRepository", err) + return + } + + total = int(count) + case "projects": + ctx.Data["OpenProjects"], _, err = models.GetProjects(models.ProjectSearchOptions{ + Page: -1, + IsClosed: util.OptionalBoolFalse, + Type: models.ProjectTypeIndividual, + }) + if err != nil { + ctx.ServerError("GetProjects", err) + return + } + case "watching": + repos, count, err = models.SearchRepository(&models.SearchRepoOptions{ + ListOptions: models.ListOptions{ + PageSize: setting.UI.User.RepoPagingNum, + Page: page, + }, + Actor: ctx.User, + Keyword: keyword, + OrderBy: orderBy, + Private: ctx.IsSigned, + WatchedByID: ctxUser.ID, + Collaborate: util.OptionalBoolFalse, + TopicOnly: topicOnly, + IncludeDescription: setting.UI.SearchRepoDescription, + }) + if err != nil { + ctx.ServerError("SearchRepository", err) + return + } + + total = int(count) + default: + repos, count, err = models.SearchRepository(&models.SearchRepoOptions{ + ListOptions: models.ListOptions{ + PageSize: setting.UI.User.RepoPagingNum, + Page: page, + }, + Actor: ctx.User, + Keyword: keyword, + OwnerID: ctxUser.ID, + OrderBy: orderBy, + Private: ctx.IsSigned, + Collaborate: util.OptionalBoolFalse, + TopicOnly: topicOnly, + IncludeDescription: setting.UI.SearchRepoDescription, + }) + if err != nil { + ctx.ServerError("SearchRepository", err) + return + } + + total = int(count) + } + ctx.Data["Repos"] = repos + ctx.Data["Total"] = total + + pager := context.NewPagination(total, setting.UI.User.RepoPagingNum, page, 5) + pager.SetDefaultParams(ctx) + ctx.Data["Page"] = pager + + ctx.Data["ShowUserEmail"] = len(ctxUser.Email) > 0 && ctx.IsSigned && (!ctxUser.KeepEmailPrivate || ctxUser.ID == ctx.User.ID) + + ctx.HTML(http.StatusOK, tplProfile) +} + +// Action response for follow/unfollow user request +func Action(ctx *context.Context) { + u := GetUserByParams(ctx) + if ctx.Written() { + return + } + + var err error + switch ctx.Params(":action") { + case "follow": + err = models.FollowUser(ctx.User.ID, u.ID) + case "unfollow": + err = models.UnfollowUser(ctx.User.ID, u.ID) + } + + if err != nil { + ctx.ServerError(fmt.Sprintf("Action (%s)", ctx.Params(":action")), err) + return + } + + ctx.RedirectToFirst(ctx.Query("redirect_to"), u.HomeLink()) +} diff --git a/routers/web/user/setting/account.go b/routers/web/user/setting/account.go new file mode 100644 index 0000000000..48ab37d936 --- /dev/null +++ b/routers/web/user/setting/account.go @@ -0,0 +1,313 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// Copyright 2018 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 setting + +import ( + "errors" + "net/http" + "time" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/password" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/timeutil" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/forms" + "code.gitea.io/gitea/services/mailer" +) + +const ( + tplSettingsAccount base.TplName = "user/settings/account" +) + +// Account renders change user's password, user's email and user suicide page +func Account(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("settings") + ctx.Data["PageIsSettingsAccount"] = true + ctx.Data["Email"] = ctx.User.Email + + loadAccountData(ctx) + + ctx.HTML(http.StatusOK, tplSettingsAccount) +} + +// AccountPost response for change user's password +func AccountPost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.ChangePasswordForm) + ctx.Data["Title"] = ctx.Tr("settings") + ctx.Data["PageIsSettingsAccount"] = true + + if ctx.HasError() { + loadAccountData(ctx) + + ctx.HTML(http.StatusOK, tplSettingsAccount) + return + } + + if len(form.Password) < setting.MinPasswordLength { + ctx.Flash.Error(ctx.Tr("auth.password_too_short", setting.MinPasswordLength)) + } else if ctx.User.IsPasswordSet() && !ctx.User.ValidatePassword(form.OldPassword) { + ctx.Flash.Error(ctx.Tr("settings.password_incorrect")) + } else if form.Password != form.Retype { + ctx.Flash.Error(ctx.Tr("form.password_not_match")) + } else if !password.IsComplexEnough(form.Password) { + ctx.Flash.Error(password.BuildComplexityError(ctx)) + } else if pwned, err := password.IsPwned(ctx, form.Password); pwned || err != nil { + errMsg := ctx.Tr("auth.password_pwned") + if err != nil { + log.Error(err.Error()) + errMsg = ctx.Tr("auth.password_pwned_err") + } + ctx.Flash.Error(errMsg) + } else { + var err error + if err = ctx.User.SetPassword(form.Password); err != nil { + ctx.ServerError("UpdateUser", err) + return + } + if err := models.UpdateUserCols(ctx.User, "salt", "passwd_hash_algo", "passwd"); err != nil { + ctx.ServerError("UpdateUser", err) + return + } + log.Trace("User password updated: %s", ctx.User.Name) + ctx.Flash.Success(ctx.Tr("settings.change_password_success")) + } + + ctx.Redirect(setting.AppSubURL + "/user/settings/account") +} + +// EmailPost response for change user's email +func EmailPost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.AddEmailForm) + ctx.Data["Title"] = ctx.Tr("settings") + ctx.Data["PageIsSettingsAccount"] = true + + // Make emailaddress primary. + if ctx.Query("_method") == "PRIMARY" { + if err := models.MakeEmailPrimary(&models.EmailAddress{ID: ctx.QueryInt64("id")}); err != nil { + ctx.ServerError("MakeEmailPrimary", err) + return + } + + log.Trace("Email made primary: %s", ctx.User.Name) + ctx.Redirect(setting.AppSubURL + "/user/settings/account") + return + } + // Send activation Email + if ctx.Query("_method") == "SENDACTIVATION" { + var address string + if ctx.Cache.IsExist("MailResendLimit_" + ctx.User.LowerName) { + log.Error("Send activation: activation still pending") + ctx.Redirect(setting.AppSubURL + "/user/settings/account") + return + } + if ctx.Query("id") == "PRIMARY" { + if ctx.User.IsActive { + log.Error("Send activation: email not set for activation") + ctx.Redirect(setting.AppSubURL + "/user/settings/account") + return + } + mailer.SendActivateAccountMail(ctx.Locale, ctx.User) + address = ctx.User.Email + } else { + id := ctx.QueryInt64("id") + email, err := models.GetEmailAddressByID(ctx.User.ID, id) + if err != nil { + log.Error("GetEmailAddressByID(%d,%d) error: %v", ctx.User.ID, id, err) + ctx.Redirect(setting.AppSubURL + "/user/settings/account") + return + } + if email == nil { + log.Error("Send activation: EmailAddress not found; user:%d, id: %d", ctx.User.ID, id) + ctx.Redirect(setting.AppSubURL + "/user/settings/account") + return + } + if email.IsActivated { + log.Error("Send activation: email not set for activation") + ctx.Redirect(setting.AppSubURL + "/user/settings/account") + return + } + mailer.SendActivateEmailMail(ctx.User, email) + address = email.Email + } + + if err := ctx.Cache.Put("MailResendLimit_"+ctx.User.LowerName, ctx.User.LowerName, 180); err != nil { + log.Error("Set cache(MailResendLimit) fail: %v", err) + } + ctx.Flash.Info(ctx.Tr("settings.add_email_confirmation_sent", address, timeutil.MinutesToFriendly(setting.Service.ActiveCodeLives, ctx.Locale.Language()))) + ctx.Redirect(setting.AppSubURL + "/user/settings/account") + return + } + // Set Email Notification Preference + if ctx.Query("_method") == "NOTIFICATION" { + preference := ctx.Query("preference") + if !(preference == models.EmailNotificationsEnabled || + preference == models.EmailNotificationsOnMention || + preference == models.EmailNotificationsDisabled) { + log.Error("Email notifications preference change returned unrecognized option %s: %s", preference, ctx.User.Name) + ctx.ServerError("SetEmailPreference", errors.New("option unrecognized")) + return + } + if err := ctx.User.SetEmailNotifications(preference); err != nil { + log.Error("Set Email Notifications failed: %v", err) + ctx.ServerError("SetEmailNotifications", err) + return + } + log.Trace("Email notifications preference made %s: %s", preference, ctx.User.Name) + ctx.Flash.Success(ctx.Tr("settings.email_preference_set_success")) + ctx.Redirect(setting.AppSubURL + "/user/settings/account") + return + } + + if ctx.HasError() { + loadAccountData(ctx) + + ctx.HTML(http.StatusOK, tplSettingsAccount) + return + } + + email := &models.EmailAddress{ + UID: ctx.User.ID, + Email: form.Email, + IsActivated: !setting.Service.RegisterEmailConfirm, + } + if err := models.AddEmailAddress(email); err != nil { + if models.IsErrEmailAlreadyUsed(err) { + loadAccountData(ctx) + + ctx.RenderWithErr(ctx.Tr("form.email_been_used"), tplSettingsAccount, &form) + return + } else if models.IsErrEmailInvalid(err) { + loadAccountData(ctx) + + ctx.RenderWithErr(ctx.Tr("form.email_invalid"), tplSettingsAccount, &form) + return + } + ctx.ServerError("AddEmailAddress", err) + return + } + + // Send confirmation email + if setting.Service.RegisterEmailConfirm { + mailer.SendActivateEmailMail(ctx.User, email) + if err := ctx.Cache.Put("MailResendLimit_"+ctx.User.LowerName, ctx.User.LowerName, 180); err != nil { + log.Error("Set cache(MailResendLimit) fail: %v", err) + } + ctx.Flash.Info(ctx.Tr("settings.add_email_confirmation_sent", email.Email, timeutil.MinutesToFriendly(setting.Service.ActiveCodeLives, ctx.Locale.Language()))) + } else { + ctx.Flash.Success(ctx.Tr("settings.add_email_success")) + } + + log.Trace("Email address added: %s", email.Email) + ctx.Redirect(setting.AppSubURL + "/user/settings/account") +} + +// DeleteEmail response for delete user's email +func DeleteEmail(ctx *context.Context) { + if err := models.DeleteEmailAddress(&models.EmailAddress{ID: ctx.QueryInt64("id"), UID: ctx.User.ID}); err != nil { + ctx.ServerError("DeleteEmail", err) + return + } + log.Trace("Email address deleted: %s", ctx.User.Name) + + ctx.Flash.Success(ctx.Tr("settings.email_deletion_success")) + ctx.JSON(http.StatusOK, map[string]interface{}{ + "redirect": setting.AppSubURL + "/user/settings/account", + }) +} + +// DeleteAccount render user suicide page and response for delete user himself +func DeleteAccount(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("settings") + ctx.Data["PageIsSettingsAccount"] = true + + if _, err := models.UserSignIn(ctx.User.Name, ctx.Query("password")); err != nil { + if models.IsErrUserNotExist(err) { + loadAccountData(ctx) + + ctx.RenderWithErr(ctx.Tr("form.enterred_invalid_password"), tplSettingsAccount, nil) + } else { + ctx.ServerError("UserSignIn", err) + } + return + } + + if err := models.DeleteUser(ctx.User); err != nil { + switch { + case models.IsErrUserOwnRepos(err): + ctx.Flash.Error(ctx.Tr("form.still_own_repo")) + ctx.Redirect(setting.AppSubURL + "/user/settings/account") + case models.IsErrUserHasOrgs(err): + ctx.Flash.Error(ctx.Tr("form.still_has_org")) + ctx.Redirect(setting.AppSubURL + "/user/settings/account") + default: + ctx.ServerError("DeleteUser", err) + } + } else { + log.Trace("Account deleted: %s", ctx.User.Name) + ctx.Redirect(setting.AppSubURL + "/") + } +} + +// UpdateUIThemePost is used to update users' specific theme +func UpdateUIThemePost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.UpdateThemeForm) + ctx.Data["Title"] = ctx.Tr("settings") + ctx.Data["PageIsSettingsAccount"] = true + + if ctx.HasError() { + ctx.Redirect(setting.AppSubURL + "/user/settings/account") + return + } + + if !form.IsThemeExists() { + ctx.Flash.Error(ctx.Tr("settings.theme_update_error")) + ctx.Redirect(setting.AppSubURL + "/user/settings/account") + return + } + + if err := ctx.User.UpdateTheme(form.Theme); err != nil { + ctx.Flash.Error(ctx.Tr("settings.theme_update_error")) + ctx.Redirect(setting.AppSubURL + "/user/settings/account") + return + } + + log.Trace("Update user theme: %s", ctx.User.Name) + ctx.Flash.Success(ctx.Tr("settings.theme_update_success")) + ctx.Redirect(setting.AppSubURL + "/user/settings/account") +} + +func loadAccountData(ctx *context.Context) { + emlist, err := models.GetEmailAddresses(ctx.User.ID) + if err != nil { + ctx.ServerError("GetEmailAddresses", err) + return + } + type UserEmail struct { + models.EmailAddress + CanBePrimary bool + } + pendingActivation := ctx.Cache.IsExist("MailResendLimit_" + ctx.User.LowerName) + emails := make([]*UserEmail, len(emlist)) + for i, em := range emlist { + var email UserEmail + email.EmailAddress = *em + email.CanBePrimary = em.IsActivated + emails[i] = &email + } + ctx.Data["Emails"] = emails + ctx.Data["EmailNotificationsPreference"] = ctx.User.EmailNotifications() + ctx.Data["ActivationsPending"] = pendingActivation + ctx.Data["CanAddEmails"] = !pendingActivation || !setting.Service.RegisterEmailConfirm + + if setting.Service.UserDeleteWithCommentsMaxTime != 0 { + ctx.Data["UserDeleteWithCommentsMaxTime"] = setting.Service.UserDeleteWithCommentsMaxTime.String() + ctx.Data["UserDeleteWithComments"] = ctx.User.CreatedUnix.AsTime().Add(setting.Service.UserDeleteWithCommentsMaxTime).After(time.Now()) + } +} diff --git a/routers/web/user/setting/account_test.go b/routers/web/user/setting/account_test.go new file mode 100644 index 0000000000..25b68da762 --- /dev/null +++ b/routers/web/user/setting/account_test.go @@ -0,0 +1,99 @@ +// Copyright 2018 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 setting + +import ( + "net/http" + "testing" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/test" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/forms" + + "github.com/stretchr/testify/assert" +) + +func TestChangePassword(t *testing.T) { + oldPassword := "password" + setting.MinPasswordLength = 6 + var pcALL = []string{"lower", "upper", "digit", "spec"} + var pcLUN = []string{"lower", "upper", "digit"} + var pcLU = []string{"lower", "upper"} + + for _, req := range []struct { + OldPassword string + NewPassword string + Retype string + Message string + PasswordComplexity []string + }{ + { + OldPassword: oldPassword, + NewPassword: "Qwerty123456-", + Retype: "Qwerty123456-", + Message: "", + PasswordComplexity: pcALL, + }, + { + OldPassword: oldPassword, + NewPassword: "12345", + Retype: "12345", + Message: "auth.password_too_short", + PasswordComplexity: pcALL, + }, + { + OldPassword: "12334", + NewPassword: "123456", + Retype: "123456", + Message: "settings.password_incorrect", + PasswordComplexity: pcALL, + }, + { + OldPassword: oldPassword, + NewPassword: "123456", + Retype: "12345", + Message: "form.password_not_match", + PasswordComplexity: pcALL, + }, + { + OldPassword: oldPassword, + NewPassword: "Qwerty", + Retype: "Qwerty", + Message: "form.password_complexity", + PasswordComplexity: pcALL, + }, + { + OldPassword: oldPassword, + NewPassword: "Qwerty", + Retype: "Qwerty", + Message: "form.password_complexity", + PasswordComplexity: pcLUN, + }, + { + OldPassword: oldPassword, + NewPassword: "QWERTY", + Retype: "QWERTY", + Message: "form.password_complexity", + PasswordComplexity: pcLU, + }, + } { + models.PrepareTestEnv(t) + ctx := test.MockContext(t, "user/settings/security") + test.LoadUser(t, ctx, 2) + test.LoadRepo(t, ctx, 1) + + web.SetForm(ctx, &forms.ChangePasswordForm{ + OldPassword: req.OldPassword, + Password: req.NewPassword, + Retype: req.Retype, + }) + AccountPost(ctx) + + assert.Contains(t, ctx.Flash.ErrorMsg, req.Message) + assert.EqualValues(t, http.StatusFound, ctx.Resp.Status()) + } +} diff --git a/routers/web/user/setting/adopt.go b/routers/web/user/setting/adopt.go new file mode 100644 index 0000000000..b2d918784f --- /dev/null +++ b/routers/web/user/setting/adopt.go @@ -0,0 +1,64 @@ +// Copyright 2020 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 setting + +import ( + "path/filepath" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/repository" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/util" +) + +// AdoptOrDeleteRepository adopts or deletes a repository +func AdoptOrDeleteRepository(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("settings") + ctx.Data["PageIsSettingsRepos"] = true + allowAdopt := ctx.IsUserSiteAdmin() || setting.Repository.AllowAdoptionOfUnadoptedRepositories + ctx.Data["allowAdopt"] = allowAdopt + allowDelete := ctx.IsUserSiteAdmin() || setting.Repository.AllowDeleteOfUnadoptedRepositories + ctx.Data["allowDelete"] = allowDelete + + dir := ctx.Query("id") + action := ctx.Query("action") + + ctxUser := ctx.User + root := filepath.Join(models.UserPath(ctxUser.LowerName)) + + // check not a repo + has, err := models.IsRepositoryExist(ctxUser, dir) + if err != nil { + ctx.ServerError("IsRepositoryExist", err) + return + } + + isDir, err := util.IsDir(filepath.Join(root, dir+".git")) + if err != nil { + ctx.ServerError("IsDir", err) + return + } + if has || !isDir { + // Fallthrough to failure mode + } else if action == "adopt" && allowAdopt { + if _, err := repository.AdoptRepository(ctxUser, ctxUser, models.CreateRepoOptions{ + Name: dir, + IsPrivate: true, + }); err != nil { + ctx.ServerError("repository.AdoptRepository", err) + return + } + ctx.Flash.Success(ctx.Tr("repo.adopt_preexisting_success", dir)) + } else if action == "delete" && allowDelete { + if err := repository.DeleteUnadoptedRepository(ctxUser, ctxUser, dir); err != nil { + ctx.ServerError("repository.AdoptRepository", err) + return + } + ctx.Flash.Success(ctx.Tr("repo.delete_preexisting_success", dir)) + } + + ctx.Redirect(setting.AppSubURL + "/user/settings/repos") +} diff --git a/routers/web/user/setting/applications.go b/routers/web/user/setting/applications.go new file mode 100644 index 0000000000..4161efdea4 --- /dev/null +++ b/routers/web/user/setting/applications.go @@ -0,0 +1,106 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// Copyright 2018 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 setting + +import ( + "net/http" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/forms" +) + +const ( + tplSettingsApplications base.TplName = "user/settings/applications" +) + +// Applications render manage access token page +func Applications(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("settings") + ctx.Data["PageIsSettingsApplications"] = true + + loadApplicationsData(ctx) + + ctx.HTML(http.StatusOK, tplSettingsApplications) +} + +// ApplicationsPost response for add user's access token +func ApplicationsPost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.NewAccessTokenForm) + ctx.Data["Title"] = ctx.Tr("settings") + ctx.Data["PageIsSettingsApplications"] = true + + if ctx.HasError() { + loadApplicationsData(ctx) + + ctx.HTML(http.StatusOK, tplSettingsApplications) + return + } + + t := &models.AccessToken{ + UID: ctx.User.ID, + Name: form.Name, + } + + exist, err := models.AccessTokenByNameExists(t) + if err != nil { + ctx.ServerError("AccessTokenByNameExists", err) + return + } + if exist { + ctx.Flash.Error(ctx.Tr("settings.generate_token_name_duplicate", t.Name)) + ctx.Redirect(setting.AppSubURL + "/user/settings/applications") + return + } + + if err := models.NewAccessToken(t); err != nil { + ctx.ServerError("NewAccessToken", err) + return + } + + ctx.Flash.Success(ctx.Tr("settings.generate_token_success")) + ctx.Flash.Info(t.Token) + + ctx.Redirect(setting.AppSubURL + "/user/settings/applications") +} + +// DeleteApplication response for delete user access token +func DeleteApplication(ctx *context.Context) { + if err := models.DeleteAccessTokenByID(ctx.QueryInt64("id"), ctx.User.ID); err != nil { + ctx.Flash.Error("DeleteAccessTokenByID: " + err.Error()) + } else { + ctx.Flash.Success(ctx.Tr("settings.delete_token_success")) + } + + ctx.JSON(http.StatusOK, map[string]interface{}{ + "redirect": setting.AppSubURL + "/user/settings/applications", + }) +} + +func loadApplicationsData(ctx *context.Context) { + tokens, err := models.ListAccessTokens(models.ListAccessTokensOptions{UserID: ctx.User.ID}) + if err != nil { + ctx.ServerError("ListAccessTokens", err) + return + } + ctx.Data["Tokens"] = tokens + ctx.Data["EnableOAuth2"] = setting.OAuth2.Enable + if setting.OAuth2.Enable { + ctx.Data["Applications"], err = models.GetOAuth2ApplicationsByUserID(ctx.User.ID) + if err != nil { + ctx.ServerError("GetOAuth2ApplicationsByUserID", err) + return + } + ctx.Data["Grants"], err = models.GetOAuth2GrantsByUserID(ctx.User.ID) + if err != nil { + ctx.ServerError("GetOAuth2GrantsByUserID", err) + return + } + } +} diff --git a/routers/web/user/setting/keys.go b/routers/web/user/setting/keys.go new file mode 100644 index 0000000000..e56a33afcb --- /dev/null +++ b/routers/web/user/setting/keys.go @@ -0,0 +1,226 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// Copyright 2018 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 setting + +import ( + "net/http" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/forms" +) + +const ( + tplSettingsKeys base.TplName = "user/settings/keys" +) + +// Keys render user's SSH/GPG public keys page +func Keys(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("settings") + ctx.Data["PageIsSettingsKeys"] = true + ctx.Data["DisableSSH"] = setting.SSH.Disabled + ctx.Data["BuiltinSSH"] = setting.SSH.StartBuiltinServer + ctx.Data["AllowPrincipals"] = setting.SSH.AuthorizedPrincipalsEnabled + + loadKeysData(ctx) + + ctx.HTML(http.StatusOK, tplSettingsKeys) +} + +// KeysPost response for change user's SSH/GPG keys +func KeysPost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.AddKeyForm) + ctx.Data["Title"] = ctx.Tr("settings") + ctx.Data["PageIsSettingsKeys"] = true + ctx.Data["DisableSSH"] = setting.SSH.Disabled + ctx.Data["BuiltinSSH"] = setting.SSH.StartBuiltinServer + ctx.Data["AllowPrincipals"] = setting.SSH.AuthorizedPrincipalsEnabled + + if ctx.HasError() { + loadKeysData(ctx) + + ctx.HTML(http.StatusOK, tplSettingsKeys) + return + } + switch form.Type { + case "principal": + content, err := models.CheckPrincipalKeyString(ctx.User, form.Content) + if err != nil { + if models.IsErrSSHDisabled(err) { + ctx.Flash.Info(ctx.Tr("settings.ssh_disabled")) + } else { + ctx.Flash.Error(ctx.Tr("form.invalid_ssh_principal", err.Error())) + } + ctx.Redirect(setting.AppSubURL + "/user/settings/keys") + return + } + if _, err = models.AddPrincipalKey(ctx.User.ID, content, 0); err != nil { + ctx.Data["HasPrincipalError"] = true + switch { + case models.IsErrKeyAlreadyExist(err), models.IsErrKeyNameAlreadyUsed(err): + loadKeysData(ctx) + + ctx.Data["Err_Content"] = true + ctx.RenderWithErr(ctx.Tr("settings.ssh_principal_been_used"), tplSettingsKeys, &form) + default: + ctx.ServerError("AddPrincipalKey", err) + } + return + } + ctx.Flash.Success(ctx.Tr("settings.add_principal_success", form.Content)) + ctx.Redirect(setting.AppSubURL + "/user/settings/keys") + case "gpg": + keys, err := models.AddGPGKey(ctx.User.ID, form.Content) + if err != nil { + ctx.Data["HasGPGError"] = true + switch { + case models.IsErrGPGKeyParsing(err): + ctx.Flash.Error(ctx.Tr("form.invalid_gpg_key", err.Error())) + ctx.Redirect(setting.AppSubURL + "/user/settings/keys") + case models.IsErrGPGKeyIDAlreadyUsed(err): + loadKeysData(ctx) + + ctx.Data["Err_Content"] = true + ctx.RenderWithErr(ctx.Tr("settings.gpg_key_id_used"), tplSettingsKeys, &form) + case models.IsErrGPGNoEmailFound(err): + loadKeysData(ctx) + + ctx.Data["Err_Content"] = true + ctx.RenderWithErr(ctx.Tr("settings.gpg_no_key_email_found"), tplSettingsKeys, &form) + default: + ctx.ServerError("AddPublicKey", err) + } + return + } + keyIDs := "" + for _, key := range keys { + keyIDs += key.KeyID + keyIDs += ", " + } + if len(keyIDs) > 0 { + keyIDs = keyIDs[:len(keyIDs)-2] + } + ctx.Flash.Success(ctx.Tr("settings.add_gpg_key_success", keyIDs)) + ctx.Redirect(setting.AppSubURL + "/user/settings/keys") + case "ssh": + content, err := models.CheckPublicKeyString(form.Content) + if err != nil { + if models.IsErrSSHDisabled(err) { + ctx.Flash.Info(ctx.Tr("settings.ssh_disabled")) + } else if models.IsErrKeyUnableVerify(err) { + ctx.Flash.Info(ctx.Tr("form.unable_verify_ssh_key")) + } else { + ctx.Flash.Error(ctx.Tr("form.invalid_ssh_key", err.Error())) + } + ctx.Redirect(setting.AppSubURL + "/user/settings/keys") + return + } + + if _, err = models.AddPublicKey(ctx.User.ID, form.Title, content, 0); err != nil { + ctx.Data["HasSSHError"] = true + switch { + case models.IsErrKeyAlreadyExist(err): + loadKeysData(ctx) + + ctx.Data["Err_Content"] = true + ctx.RenderWithErr(ctx.Tr("settings.ssh_key_been_used"), tplSettingsKeys, &form) + case models.IsErrKeyNameAlreadyUsed(err): + loadKeysData(ctx) + + ctx.Data["Err_Title"] = true + ctx.RenderWithErr(ctx.Tr("settings.ssh_key_name_used"), tplSettingsKeys, &form) + case models.IsErrKeyUnableVerify(err): + ctx.Flash.Info(ctx.Tr("form.unable_verify_ssh_key")) + ctx.Redirect(setting.AppSubURL + "/user/settings/keys") + default: + ctx.ServerError("AddPublicKey", err) + } + return + } + ctx.Flash.Success(ctx.Tr("settings.add_key_success", form.Title)) + ctx.Redirect(setting.AppSubURL + "/user/settings/keys") + + default: + ctx.Flash.Warning("Function not implemented") + ctx.Redirect(setting.AppSubURL + "/user/settings/keys") + } + +} + +// DeleteKey response for delete user's SSH/GPG key +func DeleteKey(ctx *context.Context) { + + switch ctx.Query("type") { + case "gpg": + if err := models.DeleteGPGKey(ctx.User, ctx.QueryInt64("id")); err != nil { + ctx.Flash.Error("DeleteGPGKey: " + err.Error()) + } else { + ctx.Flash.Success(ctx.Tr("settings.gpg_key_deletion_success")) + } + case "ssh": + keyID := ctx.QueryInt64("id") + external, err := models.PublicKeyIsExternallyManaged(keyID) + if err != nil { + ctx.ServerError("sshKeysExternalManaged", err) + return + } + if external { + ctx.Flash.Error(ctx.Tr("setting.ssh_externally_managed")) + ctx.Redirect(setting.AppSubURL + "/user/settings/keys") + return + } + if err := models.DeletePublicKey(ctx.User, keyID); err != nil { + ctx.Flash.Error("DeletePublicKey: " + err.Error()) + } else { + ctx.Flash.Success(ctx.Tr("settings.ssh_key_deletion_success")) + } + case "principal": + if err := models.DeletePublicKey(ctx.User, ctx.QueryInt64("id")); err != nil { + ctx.Flash.Error("DeletePublicKey: " + err.Error()) + } else { + ctx.Flash.Success(ctx.Tr("settings.ssh_principal_deletion_success")) + } + default: + ctx.Flash.Warning("Function not implemented") + ctx.Redirect(setting.AppSubURL + "/user/settings/keys") + } + ctx.JSON(http.StatusOK, map[string]interface{}{ + "redirect": setting.AppSubURL + "/user/settings/keys", + }) +} + +func loadKeysData(ctx *context.Context) { + keys, err := models.ListPublicKeys(ctx.User.ID, models.ListOptions{}) + if err != nil { + ctx.ServerError("ListPublicKeys", err) + return + } + ctx.Data["Keys"] = keys + + externalKeys, err := models.PublicKeysAreExternallyManaged(keys) + if err != nil { + ctx.ServerError("ListPublicKeys", err) + return + } + ctx.Data["ExternalKeys"] = externalKeys + + gpgkeys, err := models.ListGPGKeys(ctx.User.ID, models.ListOptions{}) + if err != nil { + ctx.ServerError("ListGPGKeys", err) + return + } + ctx.Data["GPGKeys"] = gpgkeys + + principals, err := models.ListPrincipalKeys(ctx.User.ID, models.ListOptions{}) + if err != nil { + ctx.ServerError("ListPrincipalKeys", err) + return + } + ctx.Data["Principals"] = principals +} diff --git a/routers/web/user/setting/main_test.go b/routers/web/user/setting/main_test.go new file mode 100644 index 0000000000..daa3f7fe5b --- /dev/null +++ b/routers/web/user/setting/main_test.go @@ -0,0 +1,16 @@ +// Copyright 2018 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 setting + +import ( + "path/filepath" + "testing" + + "code.gitea.io/gitea/models" +) + +func TestMain(m *testing.M) { + models.MainTest(m, filepath.Join("..", "..", "..", "..")) +} diff --git a/routers/web/user/setting/oauth2.go b/routers/web/user/setting/oauth2.go new file mode 100644 index 0000000000..c8db6e87f2 --- /dev/null +++ b/routers/web/user/setting/oauth2.go @@ -0,0 +1,159 @@ +// Copyright 2019 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 setting + +import ( + "fmt" + "net/http" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/forms" +) + +const ( + tplSettingsOAuthApplications base.TplName = "user/settings/applications_oauth2_edit" +) + +// OAuthApplicationsPost response for adding a oauth2 application +func OAuthApplicationsPost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.EditOAuth2ApplicationForm) + ctx.Data["Title"] = ctx.Tr("settings") + ctx.Data["PageIsSettingsApplications"] = true + + if ctx.HasError() { + loadApplicationsData(ctx) + + ctx.HTML(http.StatusOK, tplSettingsApplications) + return + } + // TODO validate redirect URI + app, err := models.CreateOAuth2Application(models.CreateOAuth2ApplicationOptions{ + Name: form.Name, + RedirectURIs: []string{form.RedirectURI}, + UserID: ctx.User.ID, + }) + if err != nil { + ctx.ServerError("CreateOAuth2Application", err) + return + } + ctx.Flash.Success(ctx.Tr("settings.create_oauth2_application_success")) + ctx.Data["App"] = app + ctx.Data["ClientSecret"], err = app.GenerateClientSecret() + if err != nil { + ctx.ServerError("GenerateClientSecret", err) + return + } + ctx.HTML(http.StatusOK, tplSettingsOAuthApplications) +} + +// OAuthApplicationsEdit response for editing oauth2 application +func OAuthApplicationsEdit(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.EditOAuth2ApplicationForm) + ctx.Data["Title"] = ctx.Tr("settings") + ctx.Data["PageIsSettingsApplications"] = true + + if ctx.HasError() { + loadApplicationsData(ctx) + + ctx.HTML(http.StatusOK, tplSettingsApplications) + return + } + // TODO validate redirect URI + var err error + if ctx.Data["App"], err = models.UpdateOAuth2Application(models.UpdateOAuth2ApplicationOptions{ + ID: ctx.ParamsInt64("id"), + Name: form.Name, + RedirectURIs: []string{form.RedirectURI}, + UserID: ctx.User.ID, + }); err != nil { + ctx.ServerError("UpdateOAuth2Application", err) + return + } + ctx.Flash.Success(ctx.Tr("settings.update_oauth2_application_success")) + ctx.HTML(http.StatusOK, tplSettingsOAuthApplications) +} + +// OAuthApplicationsRegenerateSecret handles the post request for regenerating the secret +func OAuthApplicationsRegenerateSecret(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("settings") + ctx.Data["PageIsSettingsApplications"] = true + + app, err := models.GetOAuth2ApplicationByID(ctx.ParamsInt64("id")) + if err != nil { + if models.IsErrOAuthApplicationNotFound(err) { + ctx.NotFound("Application not found", err) + return + } + ctx.ServerError("GetOAuth2ApplicationByID", err) + return + } + if app.UID != ctx.User.ID { + ctx.NotFound("Application not found", nil) + return + } + ctx.Data["App"] = app + ctx.Data["ClientSecret"], err = app.GenerateClientSecret() + if err != nil { + ctx.ServerError("GenerateClientSecret", err) + return + } + ctx.Flash.Success(ctx.Tr("settings.update_oauth2_application_success")) + ctx.HTML(http.StatusOK, tplSettingsOAuthApplications) +} + +// OAuth2ApplicationShow displays the given application +func OAuth2ApplicationShow(ctx *context.Context) { + app, err := models.GetOAuth2ApplicationByID(ctx.ParamsInt64("id")) + if err != nil { + if models.IsErrOAuthApplicationNotFound(err) { + ctx.NotFound("Application not found", err) + return + } + ctx.ServerError("GetOAuth2ApplicationByID", err) + return + } + if app.UID != ctx.User.ID { + ctx.NotFound("Application not found", nil) + return + } + ctx.Data["App"] = app + ctx.HTML(http.StatusOK, tplSettingsOAuthApplications) +} + +// DeleteOAuth2Application deletes the given oauth2 application +func DeleteOAuth2Application(ctx *context.Context) { + if err := models.DeleteOAuth2Application(ctx.QueryInt64("id"), ctx.User.ID); err != nil { + ctx.ServerError("DeleteOAuth2Application", err) + return + } + log.Trace("OAuth2 Application deleted: %s", ctx.User.Name) + + ctx.Flash.Success(ctx.Tr("settings.remove_oauth2_application_success")) + ctx.JSON(http.StatusOK, map[string]interface{}{ + "redirect": setting.AppSubURL + "/user/settings/applications", + }) +} + +// RevokeOAuth2Grant revokes the grant with the given id +func RevokeOAuth2Grant(ctx *context.Context) { + if ctx.User.ID == 0 || ctx.QueryInt64("id") == 0 { + ctx.ServerError("RevokeOAuth2Grant", fmt.Errorf("user id or grant id is zero")) + return + } + if err := models.RevokeOAuth2Grant(ctx.QueryInt64("id"), ctx.User.ID); err != nil { + ctx.ServerError("RevokeOAuth2Grant", err) + return + } + + ctx.Flash.Success(ctx.Tr("settings.revoke_oauth2_grant_success")) + ctx.JSON(http.StatusOK, map[string]interface{}{ + "redirect": setting.AppSubURL + "/user/settings/applications", + }) +} diff --git a/routers/web/user/setting/profile.go b/routers/web/user/setting/profile.go new file mode 100644 index 0000000000..20042caca4 --- /dev/null +++ b/routers/web/user/setting/profile.go @@ -0,0 +1,319 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// Copyright 2018 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 setting + +import ( + "errors" + "fmt" + "io/ioutil" + "net/http" + "os" + "path/filepath" + "strings" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/typesniffer" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/modules/web/middleware" + "code.gitea.io/gitea/services/forms" + + "github.com/unknwon/i18n" +) + +const ( + tplSettingsProfile base.TplName = "user/settings/profile" + tplSettingsOrganization base.TplName = "user/settings/organization" + tplSettingsRepositories base.TplName = "user/settings/repos" +) + +// Profile render user's profile page +func Profile(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("settings") + ctx.Data["PageIsSettingsProfile"] = true + + ctx.HTML(http.StatusOK, tplSettingsProfile) +} + +// HandleUsernameChange handle username changes from user settings and admin interface +func HandleUsernameChange(ctx *context.Context, user *models.User, newName string) error { + // Non-local users are not allowed to change their username. + if !user.IsLocal() { + ctx.Flash.Error(ctx.Tr("form.username_change_not_local_user")) + return fmt.Errorf(ctx.Tr("form.username_change_not_local_user")) + } + + // Check if user name has been changed + if user.LowerName != strings.ToLower(newName) { + if err := models.ChangeUserName(user, newName); err != nil { + switch { + case models.IsErrUserAlreadyExist(err): + ctx.Flash.Error(ctx.Tr("form.username_been_taken")) + case models.IsErrEmailAlreadyUsed(err): + ctx.Flash.Error(ctx.Tr("form.email_been_used")) + case models.IsErrNameReserved(err): + ctx.Flash.Error(ctx.Tr("user.form.name_reserved", newName)) + case models.IsErrNamePatternNotAllowed(err): + ctx.Flash.Error(ctx.Tr("user.form.name_pattern_not_allowed", newName)) + case models.IsErrNameCharsNotAllowed(err): + ctx.Flash.Error(ctx.Tr("user.form.name_chars_not_allowed", newName)) + default: + ctx.ServerError("ChangeUserName", err) + } + return err + } + } else { + if err := models.UpdateRepositoryOwnerNames(user.ID, newName); err != nil { + ctx.ServerError("UpdateRepository", err) + return err + } + } + log.Trace("User name changed: %s -> %s", user.Name, newName) + return nil +} + +// ProfilePost response for change user's profile +func ProfilePost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.UpdateProfileForm) + ctx.Data["Title"] = ctx.Tr("settings") + ctx.Data["PageIsSettingsProfile"] = true + + if ctx.HasError() { + ctx.HTML(http.StatusOK, tplSettingsProfile) + return + } + + if len(form.Name) != 0 && ctx.User.Name != form.Name { + log.Debug("Changing name for %s to %s", ctx.User.Name, form.Name) + if err := HandleUsernameChange(ctx, ctx.User, form.Name); err != nil { + ctx.Redirect(setting.AppSubURL + "/user/settings") + return + } + ctx.User.Name = form.Name + ctx.User.LowerName = strings.ToLower(form.Name) + } + + ctx.User.FullName = form.FullName + ctx.User.KeepEmailPrivate = form.KeepEmailPrivate + ctx.User.Website = form.Website + ctx.User.Location = form.Location + if len(form.Language) != 0 { + if !util.IsStringInSlice(form.Language, setting.Langs) { + ctx.Flash.Error(ctx.Tr("settings.update_language_not_found", form.Language)) + ctx.Redirect(setting.AppSubURL + "/user/settings") + return + } + ctx.User.Language = form.Language + } + ctx.User.Description = form.Description + ctx.User.KeepActivityPrivate = form.KeepActivityPrivate + if err := models.UpdateUserSetting(ctx.User); err != nil { + if _, ok := err.(models.ErrEmailAlreadyUsed); ok { + ctx.Flash.Error(ctx.Tr("form.email_been_used")) + ctx.Redirect(setting.AppSubURL + "/user/settings") + return + } + ctx.ServerError("UpdateUser", err) + return + } + + // Update the language to the one we just set + middleware.SetLocaleCookie(ctx.Resp, ctx.User.Language, 0) + + log.Trace("User settings updated: %s", ctx.User.Name) + ctx.Flash.Success(i18n.Tr(ctx.User.Language, "settings.update_profile_success")) + ctx.Redirect(setting.AppSubURL + "/user/settings") +} + +// UpdateAvatarSetting update user's avatar +// FIXME: limit size. +func UpdateAvatarSetting(ctx *context.Context, form *forms.AvatarForm, ctxUser *models.User) error { + ctxUser.UseCustomAvatar = form.Source == forms.AvatarLocal + if len(form.Gravatar) > 0 { + if form.Avatar != nil { + ctxUser.Avatar = base.EncodeMD5(form.Gravatar) + } else { + ctxUser.Avatar = "" + } + ctxUser.AvatarEmail = form.Gravatar + } + + if form.Avatar != nil && form.Avatar.Filename != "" { + fr, err := form.Avatar.Open() + if err != nil { + return fmt.Errorf("Avatar.Open: %v", err) + } + defer fr.Close() + + if form.Avatar.Size > setting.Avatar.MaxFileSize { + return errors.New(ctx.Tr("settings.uploaded_avatar_is_too_big")) + } + + data, err := ioutil.ReadAll(fr) + if err != nil { + return fmt.Errorf("ioutil.ReadAll: %v", err) + } + + st := typesniffer.DetectContentType(data) + if !(st.IsImage() && !st.IsSvgImage()) { + return errors.New(ctx.Tr("settings.uploaded_avatar_not_a_image")) + } + if err = ctxUser.UploadAvatar(data); err != nil { + return fmt.Errorf("UploadAvatar: %v", err) + } + } else if ctxUser.UseCustomAvatar && ctxUser.Avatar == "" { + // No avatar is uploaded but setting has been changed to enable, + // generate a random one when needed. + if err := ctxUser.GenerateRandomAvatar(); err != nil { + log.Error("GenerateRandomAvatar[%d]: %v", ctxUser.ID, err) + } + } + + if err := models.UpdateUserCols(ctxUser, "avatar", "avatar_email", "use_custom_avatar"); err != nil { + return fmt.Errorf("UpdateUser: %v", err) + } + + return nil +} + +// AvatarPost response for change user's avatar request +func AvatarPost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.AvatarForm) + if err := UpdateAvatarSetting(ctx, form, ctx.User); err != nil { + ctx.Flash.Error(err.Error()) + } else { + ctx.Flash.Success(ctx.Tr("settings.update_avatar_success")) + } + + ctx.Redirect(setting.AppSubURL + "/user/settings") +} + +// DeleteAvatar render delete avatar page +func DeleteAvatar(ctx *context.Context) { + if err := ctx.User.DeleteAvatar(); err != nil { + ctx.Flash.Error(err.Error()) + } + + ctx.Redirect(setting.AppSubURL + "/user/settings") +} + +// Organization render all the organization of the user +func Organization(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("settings") + ctx.Data["PageIsSettingsOrganization"] = true + orgs, err := models.GetOrgsByUserID(ctx.User.ID, ctx.IsSigned) + if err != nil { + ctx.ServerError("GetOrgsByUserID", err) + return + } + ctx.Data["Orgs"] = orgs + ctx.HTML(http.StatusOK, tplSettingsOrganization) +} + +// Repos display a list of all repositories of the user +func Repos(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("settings") + ctx.Data["PageIsSettingsRepos"] = true + ctx.Data["allowAdopt"] = ctx.IsUserSiteAdmin() || setting.Repository.AllowAdoptionOfUnadoptedRepositories + ctx.Data["allowDelete"] = ctx.IsUserSiteAdmin() || setting.Repository.AllowDeleteOfUnadoptedRepositories + + opts := models.ListOptions{ + PageSize: setting.UI.Admin.UserPagingNum, + Page: ctx.QueryInt("page"), + } + + if opts.Page <= 0 { + opts.Page = 1 + } + start := (opts.Page - 1) * opts.PageSize + end := start + opts.PageSize + + adoptOrDelete := ctx.IsUserSiteAdmin() || (setting.Repository.AllowAdoptionOfUnadoptedRepositories && setting.Repository.AllowDeleteOfUnadoptedRepositories) + + ctxUser := ctx.User + count := 0 + + if adoptOrDelete { + repoNames := make([]string, 0, setting.UI.Admin.UserPagingNum) + repos := map[string]*models.Repository{} + // We're going to iterate by pagesize. + root := filepath.Join(models.UserPath(ctxUser.Name)) + if err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error { + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + if !info.IsDir() || path == root { + return nil + } + name := info.Name() + if !strings.HasSuffix(name, ".git") { + return filepath.SkipDir + } + name = name[:len(name)-4] + if models.IsUsableRepoName(name) != nil || strings.ToLower(name) != name { + return filepath.SkipDir + } + if count >= start && count < end { + repoNames = append(repoNames, name) + } + count++ + return filepath.SkipDir + }); err != nil { + ctx.ServerError("filepath.Walk", err) + return + } + + if err := ctxUser.GetRepositories(models.ListOptions{Page: 1, PageSize: setting.UI.Admin.UserPagingNum}, repoNames...); err != nil { + ctx.ServerError("GetRepositories", err) + return + } + for _, repo := range ctxUser.Repos { + if repo.IsFork { + if err := repo.GetBaseRepo(); err != nil { + ctx.ServerError("GetBaseRepo", err) + return + } + } + repos[repo.LowerName] = repo + } + ctx.Data["Dirs"] = repoNames + ctx.Data["ReposMap"] = repos + } else { + var err error + var count64 int64 + ctxUser.Repos, count64, err = models.GetUserRepositories(&models.SearchRepoOptions{Actor: ctxUser, Private: true, ListOptions: opts}) + + if err != nil { + ctx.ServerError("GetRepositories", err) + return + } + count = int(count64) + repos := ctxUser.Repos + + for i := range repos { + if repos[i].IsFork { + if err := repos[i].GetBaseRepo(); err != nil { + ctx.ServerError("GetBaseRepo", err) + return + } + } + } + + ctx.Data["Repos"] = repos + } + ctx.Data["Owner"] = ctxUser + pager := context.NewPagination(int(count), opts.PageSize, opts.Page, 5) + pager.SetDefaultParams(ctx) + ctx.Data["Page"] = pager + ctx.HTML(http.StatusOK, tplSettingsRepositories) +} diff --git a/routers/web/user/setting/security.go b/routers/web/user/setting/security.go new file mode 100644 index 0000000000..7753c5c161 --- /dev/null +++ b/routers/web/user/setting/security.go @@ -0,0 +1,111 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// Copyright 2018 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 setting + +import ( + "net/http" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/setting" +) + +const ( + tplSettingsSecurity base.TplName = "user/settings/security" + tplSettingsTwofaEnroll base.TplName = "user/settings/twofa_enroll" +) + +// Security render change user's password page and 2FA +func Security(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("settings") + ctx.Data["PageIsSettingsSecurity"] = true + ctx.Data["RequireU2F"] = true + + if ctx.Query("openid.return_to") != "" { + settingsOpenIDVerify(ctx) + return + } + + loadSecurityData(ctx) + + ctx.HTML(http.StatusOK, tplSettingsSecurity) +} + +// DeleteAccountLink delete a single account link +func DeleteAccountLink(ctx *context.Context) { + id := ctx.QueryInt64("id") + if id <= 0 { + ctx.Flash.Error("Account link id is not given") + } else { + if _, err := models.RemoveAccountLink(ctx.User, id); err != nil { + ctx.Flash.Error("RemoveAccountLink: " + err.Error()) + } else { + ctx.Flash.Success(ctx.Tr("settings.remove_account_link_success")) + } + } + + ctx.JSON(http.StatusOK, map[string]interface{}{ + "redirect": setting.AppSubURL + "/user/settings/security", + }) +} + +func loadSecurityData(ctx *context.Context) { + enrolled := true + _, err := models.GetTwoFactorByUID(ctx.User.ID) + if err != nil { + if models.IsErrTwoFactorNotEnrolled(err) { + enrolled = false + } else { + ctx.ServerError("SettingsTwoFactor", err) + return + } + } + ctx.Data["TwofaEnrolled"] = enrolled + if enrolled { + ctx.Data["U2FRegistrations"], err = models.GetU2FRegistrationsByUID(ctx.User.ID) + if err != nil { + ctx.ServerError("GetU2FRegistrationsByUID", err) + return + } + } + + tokens, err := models.ListAccessTokens(models.ListAccessTokensOptions{UserID: ctx.User.ID}) + if err != nil { + ctx.ServerError("ListAccessTokens", err) + return + } + ctx.Data["Tokens"] = tokens + + accountLinks, err := models.ListAccountLinks(ctx.User) + if err != nil { + ctx.ServerError("ListAccountLinks", err) + return + } + + // map the provider display name with the LoginSource + sources := make(map[*models.LoginSource]string) + for _, externalAccount := range accountLinks { + if loginSource, err := models.GetLoginSourceByID(externalAccount.LoginSourceID); err == nil { + var providerDisplayName string + if loginSource.IsOAuth2() { + providerTechnicalName := loginSource.OAuth2().Provider + providerDisplayName = models.OAuth2Providers[providerTechnicalName].DisplayName + } else { + providerDisplayName = loginSource.Name + } + sources[loginSource] = providerDisplayName + } + } + ctx.Data["AccountLinks"] = sources + + openid, err := models.GetUserOpenIDs(ctx.User.ID) + if err != nil { + ctx.ServerError("GetUserOpenIDs", err) + return + } + ctx.Data["OpenIDs"] = openid +} diff --git a/routers/web/user/setting/security_openid.go b/routers/web/user/setting/security_openid.go new file mode 100644 index 0000000000..74dba12825 --- /dev/null +++ b/routers/web/user/setting/security_openid.go @@ -0,0 +1,129 @@ +// Copyright 2018 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 setting + +import ( + "net/http" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/auth/openid" + "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/forms" +) + +// OpenIDPost response for change user's openid +func OpenIDPost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.AddOpenIDForm) + ctx.Data["Title"] = ctx.Tr("settings") + ctx.Data["PageIsSettingsSecurity"] = true + + if ctx.HasError() { + loadSecurityData(ctx) + + ctx.HTML(http.StatusOK, tplSettingsSecurity) + 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 { + loadSecurityData(ctx) + + ctx.RenderWithErr(err.Error(), tplSettingsSecurity, &form) + return + } + form.Openid = id + log.Trace("Normalized id: " + id) + + oids, err := models.GetUserOpenIDs(ctx.User.ID) + if err != nil { + ctx.ServerError("GetUserOpenIDs", err) + return + } + ctx.Data["OpenIDs"] = oids + + // Check that the OpenID is not already used + for _, obj := range oids { + if obj.URI == id { + loadSecurityData(ctx) + + ctx.RenderWithErr(ctx.Tr("form.openid_been_used", id), tplSettingsSecurity, &form) + return + } + } + + redirectTo := setting.AppURL + "user/settings/security" + url, err := openid.RedirectURL(id, redirectTo, setting.AppURL) + if err != nil { + loadSecurityData(ctx) + + ctx.RenderWithErr(err.Error(), tplSettingsSecurity, &form) + return + } + ctx.Redirect(url) +} + +func settingsOpenIDVerify(ctx *context.Context) { + log.Trace("Incoming call to: " + ctx.Req.URL.String()) + + fullURL := setting.AppURL + ctx.Req.URL.String()[1:] + log.Trace("Full URL: " + fullURL) + + id, err := openid.Verify(fullURL) + if err != nil { + ctx.RenderWithErr(err.Error(), tplSettingsSecurity, &forms.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), tplSettingsSecurity, &forms.AddOpenIDForm{Openid: id}) + return + } + ctx.ServerError("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/security") +} + +// 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.ServerError("DeleteUserOpenID", err) + return + } + log.Trace("OpenID address deleted: %s", ctx.User.Name) + + ctx.Flash.Success(ctx.Tr("settings.openid_deletion_success")) + ctx.JSON(http.StatusOK, map[string]interface{}{ + "redirect": setting.AppSubURL + "/user/settings/security", + }) +} + +// ToggleOpenIDVisibility response for toggle visibility of user's openid +func ToggleOpenIDVisibility(ctx *context.Context) { + if err := models.ToggleUserOpenIDVisibility(ctx.QueryInt64("id")); err != nil { + ctx.ServerError("ToggleUserOpenIDVisibility", err) + return + } + + ctx.Redirect(setting.AppSubURL + "/user/settings/security") +} diff --git a/routers/web/user/setting/security_twofa.go b/routers/web/user/setting/security_twofa.go new file mode 100644 index 0000000000..7b08a05939 --- /dev/null +++ b/routers/web/user/setting/security_twofa.go @@ -0,0 +1,250 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// Copyright 2018 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 setting + +import ( + "bytes" + "encoding/base64" + "html/template" + "image/png" + "net/http" + "strings" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/forms" + + "github.com/pquerna/otp" + "github.com/pquerna/otp/totp" +) + +// RegenerateScratchTwoFactor regenerates the user's 2FA scratch code. +func RegenerateScratchTwoFactor(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("settings") + ctx.Data["PageIsSettingsSecurity"] = true + + t, err := models.GetTwoFactorByUID(ctx.User.ID) + if err != nil { + if models.IsErrTwoFactorNotEnrolled(err) { + ctx.Flash.Error(ctx.Tr("setting.twofa_not_enrolled")) + ctx.Redirect(setting.AppSubURL + "/user/settings/security") + } + ctx.ServerError("SettingsTwoFactor: Failed to GetTwoFactorByUID", err) + return + } + + token, err := t.GenerateScratchToken() + if err != nil { + ctx.ServerError("SettingsTwoFactor: Failed to GenerateScratchToken", err) + return + } + + if err = models.UpdateTwoFactor(t); err != nil { + ctx.ServerError("SettingsTwoFactor: Failed to UpdateTwoFactor", err) + return + } + + ctx.Flash.Success(ctx.Tr("settings.twofa_scratch_token_regenerated", token)) + ctx.Redirect(setting.AppSubURL + "/user/settings/security") +} + +// DisableTwoFactor deletes the user's 2FA settings. +func DisableTwoFactor(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("settings") + ctx.Data["PageIsSettingsSecurity"] = true + + t, err := models.GetTwoFactorByUID(ctx.User.ID) + if err != nil { + if models.IsErrTwoFactorNotEnrolled(err) { + ctx.Flash.Error(ctx.Tr("setting.twofa_not_enrolled")) + ctx.Redirect(setting.AppSubURL + "/user/settings/security") + } + ctx.ServerError("SettingsTwoFactor: Failed to GetTwoFactorByUID", err) + return + } + + if err = models.DeleteTwoFactorByID(t.ID, ctx.User.ID); err != nil { + if models.IsErrTwoFactorNotEnrolled(err) { + // There is a potential DB race here - we must have been disabled by another request in the intervening period + ctx.Flash.Success(ctx.Tr("settings.twofa_disabled")) + ctx.Redirect(setting.AppSubURL + "/user/settings/security") + } + ctx.ServerError("SettingsTwoFactor: Failed to DeleteTwoFactorByID", err) + return + } + + ctx.Flash.Success(ctx.Tr("settings.twofa_disabled")) + ctx.Redirect(setting.AppSubURL + "/user/settings/security") +} + +func twofaGenerateSecretAndQr(ctx *context.Context) bool { + var otpKey *otp.Key + var err error + uri := ctx.Session.Get("twofaUri") + if uri != nil { + otpKey, err = otp.NewKeyFromURL(uri.(string)) + if err != nil { + ctx.ServerError("SettingsTwoFactor: Failed NewKeyFromURL: ", err) + return false + } + } + // Filter unsafe character ':' in issuer + issuer := strings.ReplaceAll(setting.AppName+" ("+setting.Domain+")", ":", "") + if otpKey == nil { + otpKey, err = totp.Generate(totp.GenerateOpts{ + SecretSize: 40, + Issuer: issuer, + AccountName: ctx.User.Name, + }) + if err != nil { + ctx.ServerError("SettingsTwoFactor: totpGenerate Failed", err) + return false + } + } + + ctx.Data["TwofaSecret"] = otpKey.Secret() + img, err := otpKey.Image(320, 240) + if err != nil { + ctx.ServerError("SettingsTwoFactor: otpKey image generation failed", err) + return false + } + + var imgBytes bytes.Buffer + if err = png.Encode(&imgBytes, img); err != nil { + ctx.ServerError("SettingsTwoFactor: otpKey png encoding failed", err) + return false + } + + ctx.Data["QrUri"] = template.URL("data:image/png;base64," + base64.StdEncoding.EncodeToString(imgBytes.Bytes())) + + if err := ctx.Session.Set("twofaSecret", otpKey.Secret()); err != nil { + ctx.ServerError("SettingsTwoFactor: Failed to set session for twofaSecret", err) + return false + } + + if err := ctx.Session.Set("twofaUri", otpKey.String()); err != nil { + ctx.ServerError("SettingsTwoFactor: Failed to set session for twofaUri", err) + return false + } + + // Here we're just going to try to release the session early + if err := ctx.Session.Release(); err != nil { + // we'll tolerate errors here as they *should* get saved elsewhere + log.Error("Unable to save changes to the session: %v", err) + } + return true +} + +// EnrollTwoFactor shows the page where the user can enroll into 2FA. +func EnrollTwoFactor(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("settings") + ctx.Data["PageIsSettingsSecurity"] = true + + t, err := models.GetTwoFactorByUID(ctx.User.ID) + if t != nil { + // already enrolled - we should redirect back! + log.Warn("Trying to re-enroll %-v in twofa when already enrolled", ctx.User) + ctx.Flash.Error(ctx.Tr("setting.twofa_is_enrolled")) + ctx.Redirect(setting.AppSubURL + "/user/settings/security") + return + } + if err != nil && !models.IsErrTwoFactorNotEnrolled(err) { + ctx.ServerError("SettingsTwoFactor: GetTwoFactorByUID", err) + return + } + + if !twofaGenerateSecretAndQr(ctx) { + return + } + + ctx.HTML(http.StatusOK, tplSettingsTwofaEnroll) +} + +// EnrollTwoFactorPost handles enrolling the user into 2FA. +func EnrollTwoFactorPost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.TwoFactorAuthForm) + ctx.Data["Title"] = ctx.Tr("settings") + ctx.Data["PageIsSettingsSecurity"] = true + + t, err := models.GetTwoFactorByUID(ctx.User.ID) + if t != nil { + // already enrolled + ctx.Flash.Error(ctx.Tr("setting.twofa_is_enrolled")) + ctx.Redirect(setting.AppSubURL + "/user/settings/security") + return + } + if err != nil && !models.IsErrTwoFactorNotEnrolled(err) { + ctx.ServerError("SettingsTwoFactor: Failed to check if already enrolled with GetTwoFactorByUID", err) + return + } + + if ctx.HasError() { + if !twofaGenerateSecretAndQr(ctx) { + return + } + ctx.HTML(http.StatusOK, tplSettingsTwofaEnroll) + return + } + + secretRaw := ctx.Session.Get("twofaSecret") + if secretRaw == nil { + ctx.Flash.Error(ctx.Tr("settings.twofa_failed_get_secret")) + ctx.Redirect(setting.AppSubURL + "/user/settings/security/two_factor/enroll") + return + } + + secret := secretRaw.(string) + if !totp.Validate(form.Passcode, secret) { + if !twofaGenerateSecretAndQr(ctx) { + return + } + ctx.Flash.Error(ctx.Tr("settings.passcode_invalid")) + ctx.Redirect(setting.AppSubURL + "/user/settings/security/two_factor/enroll") + return + } + + t = &models.TwoFactor{ + UID: ctx.User.ID, + } + err = t.SetSecret(secret) + if err != nil { + ctx.ServerError("SettingsTwoFactor: Failed to set secret", err) + return + } + token, err := t.GenerateScratchToken() + if err != nil { + ctx.ServerError("SettingsTwoFactor: Failed to generate scratch token", err) + return + } + + // Now we have to delete the secrets - because if we fail to insert then it's highly likely that they have already been used + // If we can detect the unique constraint failure below we can move this to after the NewTwoFactor + if err := ctx.Session.Delete("twofaSecret"); err != nil { + // tolerate this failure - it's more important to continue + log.Error("Unable to delete twofaSecret from the session: Error: %v", err) + } + if err := ctx.Session.Delete("twofaUri"); err != nil { + // tolerate this failure - it's more important to continue + log.Error("Unable to delete twofaUri from the session: Error: %v", err) + } + if err := ctx.Session.Release(); err != nil { + // tolerate this failure - it's more important to continue + log.Error("Unable to save changes to the session: %v", err) + } + + if err = models.NewTwoFactor(t); err != nil { + // FIXME: We need to handle a unique constraint fail here it's entirely possible that another request has beaten us. + // If there is a unique constraint fail we should just tolerate the error + ctx.ServerError("SettingsTwoFactor: Failed to save two factor", err) + return + } + + ctx.Flash.Success(ctx.Tr("settings.twofa_enrolled", token)) + ctx.Redirect(setting.AppSubURL + "/user/settings/security") +} diff --git a/routers/web/user/setting/security_u2f.go b/routers/web/user/setting/security_u2f.go new file mode 100644 index 0000000000..f9e35549fb --- /dev/null +++ b/routers/web/user/setting/security_u2f.go @@ -0,0 +1,111 @@ +// Copyright 2018 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 setting + +import ( + "errors" + "net/http" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/forms" + + "github.com/tstranex/u2f" +) + +// U2FRegister initializes the u2f registration procedure +func U2FRegister(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.U2FRegistrationForm) + if form.Name == "" { + ctx.Error(http.StatusConflict) + return + } + challenge, err := u2f.NewChallenge(setting.U2F.AppID, setting.U2F.TrustedFacets) + if err != nil { + ctx.ServerError("NewChallenge", err) + return + } + if err := ctx.Session.Set("u2fChallenge", challenge); err != nil { + ctx.ServerError("Unable to set session key for u2fChallenge", err) + return + } + regs, err := models.GetU2FRegistrationsByUID(ctx.User.ID) + if err != nil { + ctx.ServerError("GetU2FRegistrationsByUID", err) + return + } + for _, reg := range regs { + if reg.Name == form.Name { + ctx.Error(http.StatusConflict, "Name already taken") + return + } + } + if err := ctx.Session.Set("u2fName", form.Name); err != nil { + ctx.ServerError("Unable to set session key for u2fName", err) + return + } + // Here we're just going to try to release the session early + if err := ctx.Session.Release(); err != nil { + // we'll tolerate errors here as they *should* get saved elsewhere + log.Error("Unable to save changes to the session: %v", err) + } + ctx.JSON(http.StatusOK, u2f.NewWebRegisterRequest(challenge, regs.ToRegistrations())) +} + +// U2FRegisterPost receives the response of the security key +func U2FRegisterPost(ctx *context.Context) { + response := web.GetForm(ctx).(*u2f.RegisterResponse) + challSess := ctx.Session.Get("u2fChallenge") + u2fName := ctx.Session.Get("u2fName") + if challSess == nil || u2fName == nil { + ctx.ServerError("U2FRegisterPost", errors.New("not in U2F session")) + return + } + challenge := challSess.(*u2f.Challenge) + name := u2fName.(string) + config := &u2f.Config{ + // Chrome 66+ doesn't return the device's attestation + // certificate by default. + SkipAttestationVerify: true, + } + reg, err := u2f.Register(*response, *challenge, config) + if err != nil { + ctx.ServerError("u2f.Register", err) + return + } + if _, err = models.CreateRegistration(ctx.User, name, reg); err != nil { + ctx.ServerError("u2f.Register", err) + return + } + ctx.Status(200) +} + +// U2FDelete deletes an security key by id +func U2FDelete(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.U2FDeleteForm) + reg, err := models.GetU2FRegistrationByID(form.ID) + if err != nil { + if models.IsErrU2FRegistrationNotExist(err) { + ctx.Status(200) + return + } + ctx.ServerError("GetU2FRegistrationByID", err) + return + } + if reg.UserID != ctx.User.ID { + ctx.Status(401) + return + } + if err := models.DeleteRegistration(reg); err != nil { + ctx.ServerError("DeleteRegistration", err) + return + } + ctx.JSON(http.StatusOK, map[string]interface{}{ + "redirect": setting.AppSubURL + "/user/settings/security", + }) +} diff --git a/routers/web/user/task.go b/routers/web/user/task.go new file mode 100644 index 0000000000..b8df5d99c7 --- /dev/null +++ b/routers/web/user/task.go @@ -0,0 +1,32 @@ +// Copyright 2020 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 ( + "net/http" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/context" +) + +// TaskStatus returns task's status +func TaskStatus(ctx *context.Context) { + task, opts, err := models.GetMigratingTaskByID(ctx.ParamsInt64("task"), ctx.User.ID) + if err != nil { + ctx.JSON(http.StatusInternalServerError, map[string]interface{}{ + "err": err, + }) + return + } + + ctx.JSON(http.StatusOK, map[string]interface{}{ + "status": task.Status, + "err": task.Errors, + "repo-id": task.RepoID, + "repo-name": opts.RepoName, + "start": task.StartTime, + "end": task.EndTime, + }) +} |