Skip to content
This repository has been archived by the owner on Sep 30, 2024. It is now read-only.

[CloudSaas] - Send reactivate account link for locked accounts #33810

Merged
merged 12 commits into from
Apr 13, 2022
109 changes: 109 additions & 0 deletions client/web/src/auth/UnlockAccount.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import React, { useEffect, useState } from 'react'

import * as H from 'history'
import { Redirect, RouteComponentProps } from 'react-router-dom'

import { ErrorAlert } from '@sourcegraph/branded/src/components/alerts'
import { Alert, Link, LoadingSpinner } from '@sourcegraph/wildcard'

import { AuthenticatedUser } from '../auth'
import { HeroPage } from '../components/HeroPage'
import { PageTitle } from '../components/PageTitle'
import { SourcegraphContext } from '../jscontext'
import { eventLogger } from '../tracking/eventLogger'

import { SourcegraphIcon } from './icons'
import { getReturnTo } from './SignInSignUpCommon'

import unlockAccountStyles from './SignInSignUpCommon.module.scss'

interface UnlockAccountPageProps extends RouteComponentProps<{ token: string }> {
location: H.Location
authenticatedUser: AuthenticatedUser | null
context: Pick<
SourcegraphContext,
'allowSignup' | 'authProviders' | 'sourcegraphDotComMode' | 'xhrHeaders' | 'resetPasswordEnabled'
>
}

export const UnlockAccountPage: React.FunctionComponent<UnlockAccountPageProps> = props => {
const [error, setError] = useState<Error | null>(null)
const [loading, setLoading] = useState(true)
const { token } = props.match.params

const unlockAccount = React.useCallback(async (): Promise<void> => {
try {
setLoading(true)
const response = await fetch('/-/unlock-account', {
credentials: 'same-origin',
method: 'POST',
headers: {
...props.context.xhrHeaders,
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify({
token,
}),
})

if (!response.ok) {
throw new Error('The url you provided is either expired or invalid.')
}

eventLogger.log('OkUnlockAccount', { token })
} catch (error) {
setError(error)
eventLogger.log('KoUnlockAccount', { token })
pietrorosa77 marked this conversation as resolved.
Show resolved Hide resolved
} finally {
setLoading(false)
}
}, [token, props.context.xhrHeaders])

useEffect(() => {
if (props.authenticatedUser) {
return
}
eventLogger.logPageView('UnlockUserAccountRequest', null, false)
unlockAccount().catch(error => {
setError(error)
})
}, [unlockAccount, props.authenticatedUser])

if (props.authenticatedUser) {
const returnTo = getReturnTo(props.location)
return <Redirect to={returnTo} />
}

const body = (
<div>
{loading && <LoadingSpinner />}
{error && <ErrorAlert className="mt-2" error={error} />}
{!loading && !error && (
<>
<Alert variant="success">
Your account was unlocked. Please try to <Link to="/sign-in">sign in</Link> to continue.
</Alert>
</>
)}
</div>
)

return (
<div className={unlockAccountStyles.signinSignupPage}>
<PageTitle title="Unlock account" />
<HeroPage
icon={SourcegraphIcon}
iconLinkTo={props.context.sourcegraphDotComMode ? '/search' : undefined}
iconClassName="bg-transparent"
lessPadding={true}
title={
props.context.sourcegraphDotComMode
? 'Unlock your Sourcegraph Cloud account'
: 'Unlock your Sourcegraph Server account'
pietrorosa77 marked this conversation as resolved.
Show resolved Hide resolved
}
body={body}
/>
</div>
)
}
1 change: 1 addition & 0 deletions client/web/src/routes.constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export enum PageRoutes {
SearchNotebook = '/search/notebook',
SignIn = '/sign-in',
SignUp = '/sign-up',
UnlockAccount = '/unlock-account/:token',
Welcome = '/welcome',
Settings = '/settings',
User = '/user',
Expand Down
6 changes: 6 additions & 0 deletions client/web/src/routes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ const SearchConsolePage = lazyComponent(() => import('./search/SearchConsolePage
const NotebookPage = lazyComponent(() => import('./notebooks/notebookPage/NotebookPage'), 'NotebookPage')
const SignInPage = lazyComponent(() => import('./auth/SignInPage'), 'SignInPage')
const SignUpPage = lazyComponent(() => import('./auth/SignUpPage'), 'SignUpPage')
const UnlockAccountPage = lazyComponent(() => import('./auth/UnlockAccount'), 'UnlockAccountPage')
const PostSignUpPage = lazyComponent(() => import('./auth/PostSignUpPage'), 'PostSignUpPage')
const SiteInitPage = lazyComponent(() => import('./site-admin/init/SiteInitPage'), 'SiteInitPage')

Expand Down Expand Up @@ -142,6 +143,11 @@ export const routes: readonly LayoutRouteProps<any>[] = [
render: props => <SignUpPage {...props} context={window.context} />,
exact: true,
},
{
path: PageRoutes.UnlockAccount,
render: props => <UnlockAccountPage {...props} context={window.context} />,
exact: true,
},
{
path: PageRoutes.Welcome,
render: props =>
Expand Down
2 changes: 2 additions & 0 deletions cmd/frontend/auth/non_public.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,12 +68,14 @@ var (
router.SiteInit: {},
router.SignIn: {},
router.SignOut: {},
router.UnlockAccount: {},
router.ResetPasswordInit: {},
router.ResetPasswordCode: {},
router.CheckUsernameTaken: {},
}
anonymousAccessibleUIRoutes = map[string]struct{}{
uirouter.RouteSignIn: {},
uirouter.RouteUnlockAccount: {},
uirouter.RouteSignUp: {},
uirouter.RoutePasswordReset: {},
uirouter.RoutePingFromSelfHosted: {},
Expand Down
1 change: 1 addition & 0 deletions cmd/frontend/internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ func NewHandler(db database.DB, githubAppCloudSetupHandler http.Handler) http.Ha
r.Get(router.SiteInit).Handler(trace.Route(userpasswd.HandleSiteInit(db)))
r.Get(router.SignIn).Handler(trace.Route(http.HandlerFunc(userpasswd.HandleSignIn(db, lockoutStore))))
r.Get(router.SignOut).Handler(trace.Route(http.HandlerFunc(serveSignOutHandler(db))))
r.Get(router.UnlockAccount).Handler(trace.Route(http.HandlerFunc(userpasswd.HandleUnlockAccount(db, lockoutStore))))
r.Get(router.ResetPasswordInit).Handler(trace.Route(http.HandlerFunc(userpasswd.HandleResetPasswordInit(db))))
r.Get(router.ResetPasswordCode).Handler(trace.Route(http.HandlerFunc(userpasswd.HandleResetPasswordCode(db))))
r.Get(router.VerifyEmail).Handler(trace.Route(http.HandlerFunc(serveVerifyEmail(db))))
Expand Down
2 changes: 2 additions & 0 deletions cmd/frontend/internal/app/router/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ const (
SignIn = "sign-in"
SignOut = "sign-out"
SignUp = "sign-up"
UnlockAccount = "unlock-account"
Welcome = "welcome"
SiteInit = "site-init"
VerifyEmail = "verify-email"
Expand Down Expand Up @@ -75,6 +76,7 @@ func newRouter() *mux.Router {
base.Path("/-/verify-email").Methods("GET").Name(VerifyEmail)
base.Path("/-/sign-in").Methods("POST").Name(SignIn)
base.Path("/-/sign-out").Methods("GET").Name(SignOut)
base.Path("/-/unlock-account").Methods("POST").Name(UnlockAccount)
base.Path("/-/reset-password-init").Methods("POST").Name(ResetPasswordInit)
base.Path("/-/reset-password-code").Methods("POST").Name(ResetPasswordCode)

Expand Down
2 changes: 2 additions & 0 deletions cmd/frontend/internal/app/ui/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ func newRouter() *muxtrace.Router {
r.Path("/search/console").Methods("GET").Name(routeSearchConsole)
r.Path("/sign-in").Methods("GET").Name(uirouter.RouteSignIn)
r.Path("/sign-up").Methods("GET").Name(uirouter.RouteSignUp)
r.Path("/unlock-account/{token}").Methods("GET").Name(uirouter.RouteUnlockAccount)
r.Path("/welcome").Methods("GET").Name(routeWelcome)
r.PathPrefix("/insights").Methods("GET").Name(routeInsights)
r.PathPrefix("/batch-changes").Methods("GET").Name(routeBatchChanges)
Expand Down Expand Up @@ -245,6 +246,7 @@ func initRouter(db database.DB, router *muxtrace.Router, codeIntelResolver graph
router.Get(routeContexts).Handler(brandedNoIndex("Search Contexts"))
router.Get(uirouter.RouteSignIn).Handler(handler(db, serveSignIn(db)))
router.Get(uirouter.RouteSignUp).Handler(brandedIndex("Sign up"))
router.Get(uirouter.RouteUnlockAccount).Handler(brandedNoIndex("Unlock Your Account"))
router.Get(routeWelcome).Handler(brandedNoIndex("Welcome"))
router.Get(routeOrganizations).Handler(brandedNoIndex("Organization"))
router.Get(routeSettings).Handler(brandedNoIndex("Settings"))
Expand Down
1 change: 1 addition & 0 deletions cmd/frontend/internal/app/ui/router/exported_router.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ var Router *mux.Router
const (
RouteSignIn = "sign-in"
RouteSignUp = "sign-up"
RouteUnlockAccount = "unlock-account"
RoutePasswordReset = "password-reset"
RouteRaw = "raw"
RoutePingFromSelfHosted = "ping-from-self-hosted"
Expand Down
55 changes: 55 additions & 0 deletions cmd/frontend/internal/auth/userpasswd/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@ type credentials struct {
LastSourceURL string `json:"lastSourceUrl"`
}

type unlockAccountInfo struct {
Token string `json:"token"`
}

// HandleSignUp handles submission of the user signup form.
func HandleSignUp(db database.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
Expand Down Expand Up @@ -282,6 +286,21 @@ func HandleSignIn(db database.DB, store LockoutStore) func(w http.ResponseWriter
user = *u

if reason, locked := store.IsLockedOut(user.ID); locked {
if conf.CanSendEmail() {
recipient, verified, err := db.UserEmails().GetPrimaryEmail(ctx, user.ID)
if !verified || err != nil {
// log the error and proceed
log15.Error(fmt.Sprintf("Impossible to get primary email address for userId %d. Unlock account email can't be sent", user.ID), err)
}

err = store.SendUnlockAccountEmail(ctx, user.ID, recipient)
Copy link
Member

Choose a reason for hiding this comment

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

What is the meaning of having an invalid recipient here (when we logged and proceeded with an error)?


if err != nil {
// log the error and proceed
log15.Error(fmt.Sprintf("Impossible to send unlock account email for userId %d", user.ID), err)
Comment on lines +293 to +300
Copy link
Member

Choose a reason for hiding this comment

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

log15 is structural logging, so you would actually want:

log15.Error("Impossible to get primary email address for. Unlock account email can't be sent", "userID", user.ID, "error", err)

Copy link
Member

Choose a reason for hiding this comment

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

The current usage is actually causing server panics when the logging happens, will include the fix in a PR I'm currently working on.

}
}

httpLogAndError(w, fmt.Sprintf("Account has been locked out due to %q", reason), http.StatusUnprocessableEntity)
return
}
Expand Down Expand Up @@ -310,6 +329,42 @@ func HandleSignIn(db database.DB, store LockoutStore) func(w http.ResponseWriter
}
}

func HandleUnlockAccount(db database.DB, store LockoutStore) func(w http.ResponseWriter, r *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
if handleEnabledCheck(w) {

return
}

if r.Method != http.MethodPost {
http.Error(w, fmt.Sprintf("Unsupported method %s", r.Method), http.StatusBadRequest)
return
}

var unlockAccountInfo unlockAccountInfo
if err := json.NewDecoder(r.Body).Decode(&unlockAccountInfo); err != nil {
http.Error(w, "Could not decode request body", http.StatusBadRequest)
return
}

if unlockAccountInfo.Token == "" {
http.Error(w, "Bad request: missing token", http.StatusBadRequest)
return
}

valid, error := store.VerifyUnlockAccountToken(unlockAccountInfo.Token)

if !valid || error != nil {
err := "invalid token provided"
if error != nil {
err = error.Error()
}
httpLogAndError(w, err, http.StatusUnauthorized)
return
}
}
}

func logSignInEvent(r *http.Request, db database.DB, user *types.User, name *database.SecurityEventName) {
var anonymousID string
event := &database.SecurityEvent{
Expand Down
61 changes: 61 additions & 0 deletions cmd/frontend/internal/auth/userpasswd/handlers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ func TestHandleSignIn_Lockout(t *testing.T) {
db.UsersFunc.SetDefaultReturn(users)
db.EventLogsFunc.SetDefaultReturn(database.NewMockEventLogStore())
db.SecurityEventLogsFunc.SetDefaultReturn(database.NewMockSecurityEventLogsStore())
db.UserEmailsFunc.SetDefaultReturn(database.NewMockUserEmailsStore())

lockout := NewMockLockoutStore()
h := HandleSignIn(db, lockout)
Expand All @@ -144,6 +145,7 @@ func TestHandleSignIn_Lockout(t *testing.T) {
// Getting error for locked out
{
lockout.IsLockedOutFunc.SetDefaultReturn("reason", true)
lockout.SendUnlockAccountEmailFunc.SetDefaultReturn(nil)
req, err := http.NewRequest(http.MethodPost, "/", strings.NewReader(`{}`))
require.NoError(t, err)

Expand All @@ -154,3 +156,62 @@ func TestHandleSignIn_Lockout(t *testing.T) {
assert.Equal(t, `Account has been locked out due to "reason"`+"\n", resp.Body.String())
}
}

func TestHandleAccount_Unlock(t *testing.T) {
conf.Mock(&conf.Unified{
SiteConfiguration: schema.SiteConfiguration{
AuthProviders: []schema.AuthProviders{
{
Builtin: &schema.BuiltinAuthProvider{
Type: providerType,
},
},
},
},
})
defer conf.Mock(nil)

db := database.NewMockDB()
db.EventLogsFunc.SetDefaultReturn(database.NewMockEventLogStore())
db.SecurityEventLogsFunc.SetDefaultReturn(database.NewMockSecurityEventLogsStore())

lockout := NewMockLockoutStore()
h := HandleUnlockAccount(db, lockout)

// bad request if missing token or user id
{
req, err := http.NewRequest(http.MethodPost, "/", strings.NewReader(`{}`))
require.NoError(t, err)

resp := httptest.NewRecorder()
h(resp, req)
assert.Equal(t, http.StatusBadRequest, resp.Code)
assert.Equal(t, "Bad request: missing token\n", resp.Body.String())
}

// Getting error for invalid token
{
lockout.VerifyUnlockAccountTokenFunc.SetDefaultReturn(false, errors.Newf("invalid token provided"))
req, err := http.NewRequest(http.MethodPost, "/", strings.NewReader(`{ "token": "abcd" }`))
require.NoError(t, err)

resp := httptest.NewRecorder()
h(resp, req)

assert.Equal(t, http.StatusUnauthorized, resp.Code)
assert.Equal(t, "invalid token provided\n", resp.Body.String())
}

// ok result
{
lockout.VerifyUnlockAccountTokenFunc.SetDefaultReturn(true, nil)
req, err := http.NewRequest(http.MethodPost, "/", strings.NewReader(`{ "token": "eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoxLCJpc3MiOiJodHRwczovL3NvdXJjZWdyYXBoLnRlc3Q6MzQ0MyIsInN1YiI6IjEiLCJleHAiOjE2NDk3NzgxNjl9.cm_giwkSviVRXGRCie9iii-ytJD3iAuNdtk9XmBZMrj7HHlH6vfky4ftjudAZ94HBp867cjxkuNc6OJ2uaEJFg" }`))
require.NoError(t, err)

resp := httptest.NewRecorder()
h(resp, req)

assert.Equal(t, http.StatusOK, resp.Code)
assert.Equal(t, "", resp.Body.String())
}
}
Loading