feat: add ability for users to convert their password login type to oauth/github login (#8105)

* Currently toggled by experiment flag

---------

Co-authored-by: Bruno Quaresma <bruno@coder.com>
This commit is contained in:
Steven Masley 2023-06-30 08:38:48 -04:00 committed by GitHub
parent 357f3b38f7
commit b5f26d9bdf
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
50 changed files with 2043 additions and 261 deletions

View File

@ -683,6 +683,39 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd.
}
options.AppSecurityKey = appSecurityKey
// Read the oauth signing key from the database. Like the app security, generate a new one
// if it is invalid for any reason.
oauthSigningKeyStr, err := tx.GetOAuthSigningKey(ctx)
if err != nil && !xerrors.Is(err, sql.ErrNoRows) {
return xerrors.Errorf("get app oauth signing key: %w", err)
}
if decoded, err := hex.DecodeString(oauthSigningKeyStr); err != nil || len(decoded) != len(options.OAuthSigningKey) {
b := make([]byte, len(options.OAuthSigningKey))
_, err := rand.Read(b)
if err != nil {
return xerrors.Errorf("generate fresh oauth signing key: %w", err)
}
oauthSigningKeyStr = hex.EncodeToString(b)
err = tx.UpsertOAuthSigningKey(ctx, oauthSigningKeyStr)
if err != nil {
return xerrors.Errorf("insert freshly generated oauth signing key to database: %w", err)
}
}
keyBytes, err := hex.DecodeString(oauthSigningKeyStr)
if err != nil {
return xerrors.Errorf("decode oauth signing key from database: %w", err)
}
if len(keyBytes) != len(options.OAuthSigningKey) {
return xerrors.Errorf("oauth signing key in database is not the correct length, expect %d got %d", len(options.OAuthSigningKey), len(keyBytes))
}
copy(options.OAuthSigningKey[:], keyBytes)
if options.OAuthSigningKey == [32]byte{} {
return xerrors.Errorf("oauth signing key in database is empty")
}
return nil
}, nil)
if err != nil {

142
coderd/apidoc/docs.go generated
View File

@ -3252,6 +3252,52 @@ const docTemplate = `{
}
}
},
"/users/{user}/convert-login": {
"post": {
"security": [
{
"CoderSessionToken": []
}
],
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Authorization"
],
"summary": "Convert user from password to oauth authentication",
"operationId": "convert-user-from-password-to-oauth-authentication",
"parameters": [
{
"description": "Convert request",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/codersdk.ConvertLoginRequest"
}
},
{
"type": "string",
"description": "User ID, name, or me",
"name": "user",
"in": "path",
"required": true
}
],
"responses": {
"201": {
"description": "Created",
"schema": {
"$ref": "#/definitions/codersdk.OAuthConversionResponse"
}
}
}
}
},
"/users/{user}/gitsshkey": {
"get": {
"security": [
@ -3585,6 +3631,40 @@ const docTemplate = `{
}
}
},
"/users/{user}/login-type": {
"get": {
"security": [
{
"CoderSessionToken": []
}
],
"produces": [
"application/json"
],
"tags": [
"Users"
],
"summary": "Get user login type",
"operationId": "get-user-login-type",
"parameters": [
{
"type": "string",
"description": "User ID, name, or me",
"name": "user",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/codersdk.UserLoginType"
}
}
}
}
},
"/users/{user}/organizations": {
"get": {
"security": [
@ -6650,6 +6730,9 @@ const docTemplate = `{
"codersdk.AuthMethods": {
"type": "object",
"properties": {
"convert_to_oidc_enabled": {
"type": "boolean"
},
"github": {
"$ref": "#/definitions/codersdk.AuthMethod"
},
@ -6761,6 +6844,26 @@ const docTemplate = `{
"BuildReasonAutostop"
]
},
"codersdk.ConvertLoginRequest": {
"type": "object",
"required": [
"password",
"to_type"
],
"properties": {
"password": {
"type": "string"
},
"to_type": {
"description": "ToType is the login type to convert to.",
"allOf": [
{
"$ref": "#/definitions/codersdk.LoginType"
}
]
}
}
},
"codersdk.CreateFirstUserRequest": {
"type": "object",
"required": [
@ -7540,12 +7643,14 @@ const docTemplate = `{
"enum": [
"moons",
"workspace_actions",
"tailnet_pg_coordinator"
"tailnet_pg_coordinator",
"convert-to-oidc"
],
"x-enum-varnames": [
"ExperimentMoons",
"ExperimentWorkspaceActions",
"ExperimentTailnetPGCoordinator"
"ExperimentTailnetPGCoordinator",
"ExperimentConvertToOIDC"
]
},
"codersdk.Feature": {
@ -7999,6 +8104,25 @@ const docTemplate = `{
}
}
},
"codersdk.OAuthConversionResponse": {
"type": "object",
"properties": {
"expires_at": {
"type": "string",
"format": "date-time"
},
"state_string": {
"type": "string"
},
"to_type": {
"$ref": "#/definitions/codersdk.LoginType"
},
"user_id": {
"type": "string",
"format": "uuid"
}
}
},
"codersdk.OIDCAuthMethod": {
"type": "object",
"properties": {
@ -8581,7 +8705,8 @@ const docTemplate = `{
"git_ssh_key",
"api_key",
"group",
"license"
"license",
"convert_login"
],
"x-enum-varnames": [
"ResourceTypeTemplate",
@ -8592,7 +8717,8 @@ const docTemplate = `{
"ResourceTypeGitSSHKey",
"ResourceTypeAPIKey",
"ResourceTypeGroup",
"ResourceTypeLicense"
"ResourceTypeLicense",
"ResourceTypeConvertLogin"
]
},
"codersdk.Response": {
@ -9377,6 +9503,14 @@ const docTemplate = `{
}
}
},
"codersdk.UserLoginType": {
"type": "object",
"properties": {
"login_type": {
"$ref": "#/definitions/codersdk.LoginType"
}
}
},
"codersdk.UserStatus": {
"type": "string",
"enum": [

View File

@ -2860,6 +2860,46 @@
}
}
},
"/users/{user}/convert-login": {
"post": {
"security": [
{
"CoderSessionToken": []
}
],
"consumes": ["application/json"],
"produces": ["application/json"],
"tags": ["Authorization"],
"summary": "Convert user from password to oauth authentication",
"operationId": "convert-user-from-password-to-oauth-authentication",
"parameters": [
{
"description": "Convert request",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/codersdk.ConvertLoginRequest"
}
},
{
"type": "string",
"description": "User ID, name, or me",
"name": "user",
"in": "path",
"required": true
}
],
"responses": {
"201": {
"description": "Created",
"schema": {
"$ref": "#/definitions/codersdk.OAuthConversionResponse"
}
}
}
}
},
"/users/{user}/gitsshkey": {
"get": {
"security": [
@ -3157,6 +3197,36 @@
}
}
},
"/users/{user}/login-type": {
"get": {
"security": [
{
"CoderSessionToken": []
}
],
"produces": ["application/json"],
"tags": ["Users"],
"summary": "Get user login type",
"operationId": "get-user-login-type",
"parameters": [
{
"type": "string",
"description": "User ID, name, or me",
"name": "user",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/codersdk.UserLoginType"
}
}
}
}
},
"/users/{user}/organizations": {
"get": {
"security": [
@ -5919,6 +5989,9 @@
"codersdk.AuthMethods": {
"type": "object",
"properties": {
"convert_to_oidc_enabled": {
"type": "boolean"
},
"github": {
"$ref": "#/definitions/codersdk.AuthMethod"
},
@ -6021,6 +6094,23 @@
"BuildReasonAutostop"
]
},
"codersdk.ConvertLoginRequest": {
"type": "object",
"required": ["password", "to_type"],
"properties": {
"password": {
"type": "string"
},
"to_type": {
"description": "ToType is the login type to convert to.",
"allOf": [
{
"$ref": "#/definitions/codersdk.LoginType"
}
]
}
}
},
"codersdk.CreateFirstUserRequest": {
"type": "object",
"required": ["email", "password", "username"],
@ -6746,11 +6836,17 @@
},
"codersdk.Experiment": {
"type": "string",
"enum": ["moons", "workspace_actions", "tailnet_pg_coordinator"],
"enum": [
"moons",
"workspace_actions",
"tailnet_pg_coordinator",
"convert-to-oidc"
],
"x-enum-varnames": [
"ExperimentMoons",
"ExperimentWorkspaceActions",
"ExperimentTailnetPGCoordinator"
"ExperimentTailnetPGCoordinator",
"ExperimentConvertToOIDC"
]
},
"codersdk.Feature": {
@ -7170,6 +7266,25 @@
}
}
},
"codersdk.OAuthConversionResponse": {
"type": "object",
"properties": {
"expires_at": {
"type": "string",
"format": "date-time"
},
"state_string": {
"type": "string"
},
"to_type": {
"$ref": "#/definitions/codersdk.LoginType"
},
"user_id": {
"type": "string",
"format": "uuid"
}
}
},
"codersdk.OIDCAuthMethod": {
"type": "object",
"properties": {
@ -7718,7 +7833,8 @@
"git_ssh_key",
"api_key",
"group",
"license"
"license",
"convert_login"
],
"x-enum-varnames": [
"ResourceTypeTemplate",
@ -7729,7 +7845,8 @@
"ResourceTypeGitSSHKey",
"ResourceTypeAPIKey",
"ResourceTypeGroup",
"ResourceTypeLicense"
"ResourceTypeLicense",
"ResourceTypeConvertLogin"
]
},
"codersdk.Response": {
@ -8465,6 +8582,14 @@
}
}
},
"codersdk.UserLoginType": {
"type": "object",
"properties": {
"login_type": {
"$ref": "#/definitions/codersdk.LoginType"
}
}
},
"codersdk.UserStatus": {
"type": "string",
"enum": ["active", "suspended"],

View File

@ -271,6 +271,10 @@ func auditLogDescription(alog database.GetAuditLogsOffsetRow) string {
str += fmt.Sprintf(" %s",
codersdk.ResourceType(alog.ResourceType).FriendlyString())
if alog.ResourceType == database.ResourceTypeConvertLogin {
str += " to"
}
str += " {target}"
return str

View File

@ -17,7 +17,8 @@ type Auditable interface {
database.WorkspaceBuild |
database.AuditableGroup |
database.License |
database.WorkspaceProxy
database.WorkspaceProxy |
database.AuditOAuthConvertState
}
// Map is a map of changed fields in an audited resource. It maps field names to

View File

@ -84,6 +84,8 @@ func ResourceTarget[T Auditable](tgt T) string {
return strconv.Itoa(int(typed.ID))
case database.WorkspaceProxy:
return typed.Name
case database.AuditOAuthConvertState:
return string(typed.ToLoginType)
default:
panic(fmt.Sprintf("unknown resource %T", tgt))
}
@ -111,6 +113,9 @@ func ResourceID[T Auditable](tgt T) uuid.UUID {
return typed.UUID
case database.WorkspaceProxy:
return typed.ID
case database.AuditOAuthConvertState:
// The merge state is for the given user
return typed.UserID
default:
panic(fmt.Sprintf("unknown resource %T", tgt))
}
@ -138,6 +143,8 @@ func ResourceType[T Auditable](tgt T) database.ResourceType {
return database.ResourceTypeLicense
case database.WorkspaceProxy:
return database.ResourceTypeWorkspaceProxy
case database.AuditOAuthConvertState:
return database.ResourceTypeConvertLogin
default:
panic(fmt.Sprintf("unknown resource %T", typed))
}

View File

@ -132,6 +132,11 @@ type Options struct {
HealthcheckTimeout time.Duration
HealthcheckRefresh time.Duration
// OAuthSigningKey is the crypto key used to sign and encrypt state strings
// related to OAuth. This is a symmetric secret key using hmac to sign payloads.
// So this secret should **never** be exposed to the client.
OAuthSigningKey [32]byte
// APIRateLimit is the minutely throughput rate limit per user or ip.
// Setting a rate limit <0 will disable the rate limiter across the entire
// app. Some specific routes have their own configurable rate limits.
@ -309,9 +314,16 @@ func New(options *Options) *API {
ctx, cancel := context.WithCancel(context.Background())
r := chi.NewRouter()
// nolint:gocritic // Load deployment ID. This never changes
depID, err := options.Database.GetDeploymentID(dbauthz.AsSystemRestricted(ctx))
if err != nil {
panic(xerrors.Errorf("get deployment ID: %w", err))
}
api := &API{
ctx: ctx,
cancel: cancel,
ctx: ctx,
cancel: cancel,
DeploymentID: depID,
ID: uuid.New(),
Options: options,
@ -608,6 +620,7 @@ func New(options *Options) *API {
r.Get("/first", api.firstUser)
r.Post("/first", api.postFirstUser)
r.Get("/authmethods", api.userAuthMethods)
r.Group(func(r chi.Router) {
// We use a tight limit for password login to protect against
// audit-log write DoS, pbkdf2 DoS, and simple brute-force
@ -640,8 +653,10 @@ func New(options *Options) *API {
})
r.Route("/{user}", func(r chi.Router) {
r.Use(httpmw.ExtractUserParam(options.Database, false))
r.Post("/convert-login", api.postConvertLoginType)
r.Delete("/", api.deleteUser)
r.Get("/", api.userByName)
r.Get("/login-type", api.userLoginType)
r.Put("/profile", api.putUserProfile)
r.Route("/status", func(r chi.Router) {
r.Put("/suspend", api.putSuspendUserAccount())
@ -858,6 +873,9 @@ type API struct {
ctx context.Context
cancel context.CancelFunc
// DeploymentID is loaded from the database on startup.
DeploymentID string
*Options
// ID is a uniquely generated ID on initialization.
// This is used to associate objects with a specific

View File

@ -1041,6 +1041,13 @@ func (q *querier) GetLogoURL(ctx context.Context) (string, error) {
return q.db.GetLogoURL(ctx)
}
func (q *querier) GetOAuthSigningKey(ctx context.Context) (string, error) {
if err := q.authorizeContext(ctx, rbac.ActionUpdate, rbac.ResourceSystem); err != nil {
return "", err
}
return q.db.GetOAuthSigningKey(ctx)
}
func (q *querier) GetOrganizationByID(ctx context.Context, id uuid.UUID) (database.Organization, error) {
return fetch(q.log, q.auth, q.db.GetOrganizationByID)(ctx, id)
}
@ -2351,6 +2358,13 @@ func (q *querier) UpdateUserLinkedID(ctx context.Context, arg database.UpdateUse
return q.db.UpdateUserLinkedID(ctx, arg)
}
func (q *querier) UpdateUserLoginType(ctx context.Context, arg database.UpdateUserLoginTypeParams) (database.User, error) {
if err := q.authorizeContext(ctx, rbac.ActionUpdate, rbac.ResourceSystem); err != nil {
return database.User{}, err
}
return q.db.UpdateUserLoginType(ctx, arg)
}
func (q *querier) UpdateUserProfile(ctx context.Context, arg database.UpdateUserProfileParams) (database.User, error) {
u, err := q.db.GetUserByID(ctx, arg.ID)
if err != nil {
@ -2594,6 +2608,13 @@ func (q *querier) UpsertLogoURL(ctx context.Context, value string) error {
return q.db.UpsertLogoURL(ctx, value)
}
func (q *querier) UpsertOAuthSigningKey(ctx context.Context, value string) error {
if err := q.authorizeContext(ctx, rbac.ActionUpdate, rbac.ResourceSystem); err != nil {
return err
}
return q.db.UpsertOAuthSigningKey(ctx, value)
}
func (q *querier) UpsertServiceBanner(ctx context.Context, value string) error {
if err := q.authorizeContext(ctx, rbac.ActionCreate, rbac.ResourceDeploymentValues); err != nil {
return err

View File

@ -139,7 +139,6 @@ type data struct {
workspaceResources []database.WorkspaceResource
workspaces []database.Workspace
workspaceProxies []database.WorkspaceProxy
// Locks is a map of lock names. Any keys within the map are currently
// locked.
locks map[int64]struct{}
@ -149,6 +148,7 @@ type data struct {
serviceBanner []byte
logoURL string
appSecurityKey string
oauthSigningKey string
lastLicenseID int32
defaultProxyDisplayName string
defaultProxyIconURL string
@ -1868,6 +1868,13 @@ func (q *fakeQuerier) GetLogoURL(_ context.Context) (string, error) {
return q.logoURL, nil
}
func (q *fakeQuerier) GetOAuthSigningKey(_ context.Context) (string, error) {
q.mutex.RLock()
defer q.mutex.RUnlock()
return q.oauthSigningKey, nil
}
func (q *fakeQuerier) GetOrganizationByID(_ context.Context, id uuid.UUID) (database.Organization, error) {
q.mutex.RLock()
defer q.mutex.RUnlock()
@ -4877,6 +4884,27 @@ func (q *fakeQuerier) UpdateUserLinkedID(_ context.Context, params database.Upda
return database.UserLink{}, sql.ErrNoRows
}
func (q *fakeQuerier) UpdateUserLoginType(_ context.Context, arg database.UpdateUserLoginTypeParams) (database.User, error) {
if err := validateDatabaseType(arg); err != nil {
return database.User{}, err
}
q.mutex.Lock()
defer q.mutex.Unlock()
for i, u := range q.users {
if u.ID == arg.UserID {
u.LoginType = arg.NewLoginType
if arg.NewLoginType != database.LoginTypePassword {
u.HashedPassword = []byte{}
}
q.users[i] = u
return u, nil
}
}
return database.User{}, sql.ErrNoRows
}
func (q *fakeQuerier) UpdateUserProfile(_ context.Context, arg database.UpdateUserProfileParams) (database.User, error) {
if err := validateDatabaseType(arg); err != nil {
return database.User{}, err
@ -5334,6 +5362,14 @@ func (q *fakeQuerier) UpsertLogoURL(_ context.Context, data string) error {
return nil
}
func (q *fakeQuerier) UpsertOAuthSigningKey(_ context.Context, value string) error {
q.mutex.Lock()
defer q.mutex.Unlock()
q.oauthSigningKey = value
return nil
}
func (q *fakeQuerier) UpsertServiceBanner(_ context.Context, data string) error {
q.mutex.RLock()
defer q.mutex.RUnlock()

View File

@ -199,7 +199,7 @@ func User(t testing.TB, db database.Store, orig database.User) database.User {
ID: takeFirst(orig.ID, uuid.New()),
Email: takeFirst(orig.Email, namesgenerator.GetRandomName(1)),
Username: takeFirst(orig.Username, namesgenerator.GetRandomName(1)),
HashedPassword: takeFirstSlice(orig.HashedPassword, []byte{}),
HashedPassword: takeFirstSlice(orig.HashedPassword, []byte(must(cryptorand.String(32)))),
CreatedAt: takeFirst(orig.CreatedAt, database.Now()),
UpdatedAt: takeFirst(orig.UpdatedAt, database.Now()),
RBACRoles: takeFirstSlice(orig.RBACRoles, []string{}),

View File

@ -455,6 +455,13 @@ func (m metricsStore) GetLogoURL(ctx context.Context) (string, error) {
return url, err
}
func (m metricsStore) GetOAuthSigningKey(ctx context.Context) (string, error) {
start := time.Now()
r0, r1 := m.s.GetOAuthSigningKey(ctx)
m.queryLatencies.WithLabelValues("GetOAuthSigningKey").Observe(time.Since(start).Seconds())
return r0, r1
}
func (m metricsStore) GetOrganizationByID(ctx context.Context, id uuid.UUID) (database.Organization, error) {
start := time.Now()
organization, err := m.s.GetOrganizationByID(ctx, id)
@ -1426,6 +1433,13 @@ func (m metricsStore) UpdateUserLinkedID(ctx context.Context, arg database.Updat
return link, err
}
func (m metricsStore) UpdateUserLoginType(ctx context.Context, arg database.UpdateUserLoginTypeParams) (database.User, error) {
start := time.Now()
r0, r1 := m.s.UpdateUserLoginType(ctx, arg)
m.queryLatencies.WithLabelValues("UpdateUserLoginType").Observe(time.Since(start).Seconds())
return r0, r1
}
func (m metricsStore) UpdateUserProfile(ctx context.Context, arg database.UpdateUserProfileParams) (database.User, error) {
start := time.Now()
user, err := m.s.UpdateUserProfile(ctx, arg)
@ -1594,6 +1608,13 @@ func (m metricsStore) UpsertLogoURL(ctx context.Context, value string) error {
return r0
}
func (m metricsStore) UpsertOAuthSigningKey(ctx context.Context, value string) error {
start := time.Now()
r0 := m.s.UpsertOAuthSigningKey(ctx, value)
m.queryLatencies.WithLabelValues("UpsertOAuthSigningKey").Observe(time.Since(start).Seconds())
return r0
}
func (m metricsStore) UpsertServiceBanner(ctx context.Context, value string) error {
start := time.Now()
r0 := m.s.UpsertServiceBanner(ctx, value)

View File

@ -821,6 +821,21 @@ func (mr *MockStoreMockRecorder) GetLogoURL(arg0 interface{}) *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetLogoURL", reflect.TypeOf((*MockStore)(nil).GetLogoURL), arg0)
}
// GetOAuthSigningKey mocks base method.
func (m *MockStore) GetOAuthSigningKey(arg0 context.Context) (string, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetOAuthSigningKey", arg0)
ret0, _ := ret[0].(string)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetOAuthSigningKey indicates an expected call of GetOAuthSigningKey.
func (mr *MockStoreMockRecorder) GetOAuthSigningKey(arg0 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetOAuthSigningKey", reflect.TypeOf((*MockStore)(nil).GetOAuthSigningKey), arg0)
}
// GetOrganizationByID mocks base method.
func (m *MockStore) GetOrganizationByID(arg0 context.Context, arg1 uuid.UUID) (database.Organization, error) {
m.ctrl.T.Helper()
@ -2949,6 +2964,21 @@ func (mr *MockStoreMockRecorder) UpdateUserLinkedID(arg0, arg1 interface{}) *gom
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateUserLinkedID", reflect.TypeOf((*MockStore)(nil).UpdateUserLinkedID), arg0, arg1)
}
// UpdateUserLoginType mocks base method.
func (m *MockStore) UpdateUserLoginType(arg0 context.Context, arg1 database.UpdateUserLoginTypeParams) (database.User, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "UpdateUserLoginType", arg0, arg1)
ret0, _ := ret[0].(database.User)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// UpdateUserLoginType indicates an expected call of UpdateUserLoginType.
func (mr *MockStoreMockRecorder) UpdateUserLoginType(arg0, arg1 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateUserLoginType", reflect.TypeOf((*MockStore)(nil).UpdateUserLoginType), arg0, arg1)
}
// UpdateUserProfile mocks base method.
func (m *MockStore) UpdateUserProfile(arg0 context.Context, arg1 database.UpdateUserProfileParams) (database.User, error) {
m.ctrl.T.Helper()
@ -3292,6 +3322,20 @@ func (mr *MockStoreMockRecorder) UpsertLogoURL(arg0, arg1 interface{}) *gomock.C
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertLogoURL", reflect.TypeOf((*MockStore)(nil).UpsertLogoURL), arg0, arg1)
}
// UpsertOAuthSigningKey mocks base method.
func (m *MockStore) UpsertOAuthSigningKey(arg0 context.Context, arg1 string) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "UpsertOAuthSigningKey", arg0, arg1)
ret0, _ := ret[0].(error)
return ret0
}
// UpsertOAuthSigningKey indicates an expected call of UpsertOAuthSigningKey.
func (mr *MockStoreMockRecorder) UpsertOAuthSigningKey(arg0, arg1 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertOAuthSigningKey", reflect.TypeOf((*MockStore)(nil).UpsertOAuthSigningKey), arg0, arg1)
}
// UpsertServiceBanner mocks base method.
func (m *MockStore) UpsertServiceBanner(arg0 context.Context, arg1 string) error {
m.ctrl.T.Helper()

View File

@ -99,7 +99,8 @@ CREATE TYPE resource_type AS ENUM (
'group',
'workspace_build',
'license',
'workspace_proxy'
'workspace_proxy',
'convert_login'
);
CREATE TYPE startup_script_behavior AS ENUM (

View File

@ -0,0 +1 @@
-- Nothing to do

View File

@ -0,0 +1,2 @@
-- This has to be outside a transaction
ALTER TYPE resource_type ADD VALUE IF NOT EXISTS 'convert_login';

View File

@ -891,6 +891,7 @@ const (
ResourceTypeWorkspaceBuild ResourceType = "workspace_build"
ResourceTypeLicense ResourceType = "license"
ResourceTypeWorkspaceProxy ResourceType = "workspace_proxy"
ResourceTypeConvertLogin ResourceType = "convert_login"
)
func (e *ResourceType) Scan(src interface{}) error {
@ -940,7 +941,8 @@ func (e ResourceType) Valid() bool {
ResourceTypeGroup,
ResourceTypeWorkspaceBuild,
ResourceTypeLicense,
ResourceTypeWorkspaceProxy:
ResourceTypeWorkspaceProxy,
ResourceTypeConvertLogin:
return true
}
return false
@ -959,6 +961,7 @@ func AllResourceTypeValues() []ResourceType {
ResourceTypeWorkspaceBuild,
ResourceTypeLicense,
ResourceTypeWorkspaceProxy,
ResourceTypeConvertLogin,
}
}

View File

@ -81,6 +81,7 @@ type sqlcQuerier interface {
GetLicenseByID(ctx context.Context, id int32) (License, error)
GetLicenses(ctx context.Context) ([]License, error)
GetLogoURL(ctx context.Context) (string, error)
GetOAuthSigningKey(ctx context.Context) (string, error)
GetOrganizationByID(ctx context.Context, id uuid.UUID) (Organization, error)
GetOrganizationByName(ctx context.Context, name string) (Organization, error)
GetOrganizationIDsByMemberIDs(ctx context.Context, ids []uuid.UUID) ([]GetOrganizationIDsByMemberIDsRow, error)
@ -239,6 +240,7 @@ type sqlcQuerier interface {
UpdateUserLastSeenAt(ctx context.Context, arg UpdateUserLastSeenAtParams) (User, error)
UpdateUserLink(ctx context.Context, arg UpdateUserLinkParams) (UserLink, error)
UpdateUserLinkedID(ctx context.Context, arg UpdateUserLinkedIDParams) (UserLink, error)
UpdateUserLoginType(ctx context.Context, arg UpdateUserLoginTypeParams) (User, error)
UpdateUserProfile(ctx context.Context, arg UpdateUserProfileParams) (User, error)
UpdateUserRoles(ctx context.Context, arg UpdateUserRolesParams) (User, error)
UpdateUserStatus(ctx context.Context, arg UpdateUserStatusParams) (User, error)
@ -267,6 +269,7 @@ type sqlcQuerier interface {
UpsertDefaultProxy(ctx context.Context, arg UpsertDefaultProxyParams) error
UpsertLastUpdateCheck(ctx context.Context, value string) error
UpsertLogoURL(ctx context.Context, value string) error
UpsertOAuthSigningKey(ctx context.Context, value string) error
UpsertServiceBanner(ctx context.Context, value string) error
UpsertTailnetAgent(ctx context.Context, arg UpsertTailnetAgentParams) (TailnetAgent, error)
UpsertTailnetClient(ctx context.Context, arg UpsertTailnetClientParams) (TailnetClient, error)

View File

@ -441,6 +441,53 @@ func TestUserLastSeenFilter(t *testing.T) {
})
}
func TestUserChangeLoginType(t *testing.T) {
t.Parallel()
if testing.Short() {
t.SkipNow()
}
sqlDB := testSQLDB(t)
err := migrations.Up(sqlDB)
require.NoError(t, err)
db := database.New(sqlDB)
ctx := context.Background()
alice := dbgen.User(t, db, database.User{
LoginType: database.LoginTypePassword,
})
bob := dbgen.User(t, db, database.User{
LoginType: database.LoginTypePassword,
})
bobExpPass := bob.HashedPassword
require.NotEmpty(t, alice.HashedPassword, "hashed password should not start empty")
require.NotEmpty(t, bob.HashedPassword, "hashed password should not start empty")
alice, err = db.UpdateUserLoginType(ctx, database.UpdateUserLoginTypeParams{
NewLoginType: database.LoginTypeOIDC,
UserID: alice.ID,
})
require.NoError(t, err)
require.Empty(t, alice.HashedPassword, "hashed password should be empty")
// First check other users are not affected
bob, err = db.GetUserByID(ctx, bob.ID)
require.NoError(t, err)
require.Equal(t, bobExpPass, bob.HashedPassword, "hashed password should not change")
// Then check password -> password is a noop
bob, err = db.UpdateUserLoginType(ctx, database.UpdateUserLoginTypeParams{
NewLoginType: database.LoginTypePassword,
UserID: bob.ID,
})
require.NoError(t, err)
bob, err = db.GetUserByID(ctx, bob.ID)
require.NoError(t, err)
require.Equal(t, bobExpPass, bob.HashedPassword, "hashed password should not change")
}
func requireUsersMatch(t testing.TB, expected []database.User, found []database.GetUsersRow, msg string) {
t.Helper()
require.ElementsMatch(t, expected, database.ConvertUserRows(found), msg)

View File

@ -3218,6 +3218,17 @@ func (q *sqlQuerier) GetLogoURL(ctx context.Context) (string, error) {
return value, err
}
const getOAuthSigningKey = `-- name: GetOAuthSigningKey :one
SELECT value FROM site_configs WHERE key = 'oauth_signing_key'
`
func (q *sqlQuerier) GetOAuthSigningKey(ctx context.Context) (string, error) {
row := q.db.QueryRowContext(ctx, getOAuthSigningKey)
var value string
err := row.Scan(&value)
return value, err
}
const getServiceBanner = `-- name: GetServiceBanner :one
SELECT value FROM site_configs WHERE key = 'service_banner'
`
@ -3300,6 +3311,16 @@ func (q *sqlQuerier) UpsertLogoURL(ctx context.Context, value string) error {
return err
}
const upsertOAuthSigningKey = `-- name: UpsertOAuthSigningKey :exec
INSERT INTO site_configs (key, value) VALUES ('oauth_signing_key', $1)
ON CONFLICT (key) DO UPDATE set value = $1 WHERE site_configs.key = 'oauth_signing_key'
`
func (q *sqlQuerier) UpsertOAuthSigningKey(ctx context.Context, value string) error {
_, err := q.db.ExecContext(ctx, upsertOAuthSigningKey, value)
return err
}
const upsertServiceBanner = `-- name: UpsertServiceBanner :exec
INSERT INTO site_configs (key, value) VALUES ('service_banner', $1)
ON CONFLICT (key) DO UPDATE SET value = $1 WHERE site_configs.key = 'service_banner'
@ -4999,7 +5020,7 @@ SELECT
FROM
users
WHERE
status = 'active'::user_status AND deleted = false
status = 'active'::user_status AND deleted = false
`
func (q *sqlQuerier) GetActiveUserCount(ctx context.Context) (int64, error) {
@ -5251,9 +5272,9 @@ WHERE
-- Filter by rbac_roles
AND CASE
-- @rbac_role allows filtering by rbac roles. If 'member' is included, show everyone, as
-- everyone is a member.
-- everyone is a member.
WHEN cardinality($4 :: text[]) > 0 AND 'member' != ANY($4 :: text[]) THEN
rbac_roles && $4 :: text[]
rbac_roles && $4 :: text[]
ELSE true
END
-- Filter by last_seen
@ -5523,6 +5544,47 @@ func (q *sqlQuerier) UpdateUserLastSeenAt(ctx context.Context, arg UpdateUserLas
return i, err
}
const updateUserLoginType = `-- name: UpdateUserLoginType :one
UPDATE
users
SET
login_type = $1,
hashed_password = CASE WHEN $1 = 'password' :: login_type THEN
users.hashed_password
ELSE
-- If the login type is not password, then the password should be
-- cleared.
'':: bytea
END
WHERE
id = $2 RETURNING id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at
`
type UpdateUserLoginTypeParams struct {
NewLoginType LoginType `db:"new_login_type" json:"new_login_type"`
UserID uuid.UUID `db:"user_id" json:"user_id"`
}
func (q *sqlQuerier) UpdateUserLoginType(ctx context.Context, arg UpdateUserLoginTypeParams) (User, error) {
row := q.db.QueryRowContext(ctx, updateUserLoginType, arg.NewLoginType, arg.UserID)
var i User
err := row.Scan(
&i.ID,
&i.Email,
&i.Username,
&i.HashedPassword,
&i.CreatedAt,
&i.UpdatedAt,
&i.Status,
&i.RBACRoles,
&i.LoginType,
&i.AvatarURL,
&i.Deleted,
&i.LastSeenAt,
)
return i, err
}
const updateUserProfile = `-- name: UpdateUserProfile :one
UPDATE
users

View File

@ -17,7 +17,6 @@ SELECT
COALESCE((SELECT value FROM site_configs WHERE key = 'default_proxy_icon_url'), '/emojis/1f3e1.png') :: text AS icon_url
;
-- name: InsertDeploymentID :exec
INSERT INTO site_configs (key, value) VALUES ('deployment_id', $1);
@ -57,3 +56,10 @@ SELECT value FROM site_configs WHERE key = 'app_signing_key';
-- name: UpsertAppSecurityKey :exec
INSERT INTO site_configs (key, value) VALUES ('app_signing_key', $1)
ON CONFLICT (key) DO UPDATE set value = $1 WHERE site_configs.key = 'app_signing_key';
-- name: GetOAuthSigningKey :one
SELECT value FROM site_configs WHERE key = 'oauth_signing_key';
-- name: UpsertOAuthSigningKey :exec
INSERT INTO site_configs (key, value) VALUES ('oauth_signing_key', $1)
ON CONFLICT (key) DO UPDATE set value = $1 WHERE site_configs.key = 'oauth_signing_key';

View File

@ -1,3 +1,18 @@
-- name: UpdateUserLoginType :one
UPDATE
users
SET
login_type = @new_login_type,
hashed_password = CASE WHEN @new_login_type = 'password' :: login_type THEN
users.hashed_password
ELSE
-- If the login type is not password, then the password should be
-- cleared.
'':: bytea
END
WHERE
id = @user_id RETURNING *;
-- name: GetUserByID :one
SELECT
*
@ -39,7 +54,7 @@ SELECT
FROM
users
WHERE
status = 'active'::user_status AND deleted = false;
status = 'active'::user_status AND deleted = false;
-- name: GetFilteredUserCount :one
-- This will never count deleted users.
@ -176,9 +191,9 @@ WHERE
-- Filter by rbac_roles
AND CASE
-- @rbac_role allows filtering by rbac roles. If 'member' is included, show everyone, as
-- everyone is a member.
-- everyone is a member.
WHEN cardinality(@rbac_role :: text[]) > 0 AND 'member' != ANY(@rbac_role :: text[]) THEN
rbac_roles && @rbac_role :: text[]
rbac_roles && @rbac_role :: text[]
ELSE true
END
-- Filter by last_seen

View File

@ -5,11 +5,24 @@ import (
"encoding/json"
"time"
"github.com/google/uuid"
"golang.org/x/xerrors"
"github.com/coder/coder/coderd/rbac"
)
// AuditOAuthConvertState is never stored in the database. It is stored in a cookie
// clientside as a JWT. This type is provided for audit logging purposes.
type AuditOAuthConvertState struct {
CreatedAt time.Time `db:"created_at" json:"created_at"`
// The time at which the state string expires, a merge request times out if the user does not perform it quick enough.
ExpiresAt time.Time `db:"expires_at" json:"expires_at"`
FromLoginType LoginType `db:"from_login_type" json:"from_login_type"`
// The login type the user is converting to. Should be github or oidc.
ToLoginType LoginType `db:"to_login_type" json:"to_login_type"`
UserID uuid.UUID `db:"user_id" json:"user_id"`
}
type Actions []rbac.Action
func (a *Actions) Scan(src interface{}) error {

View File

@ -16,8 +16,9 @@ import (
type oauth2StateKey struct{}
type OAuth2State struct {
Token *oauth2.Token
Redirect string
Token *oauth2.Token
Redirect string
StateString string
}
// OAuth2Config exposes a subset of *oauth2.Config functions for easier testing.
@ -91,13 +92,24 @@ func ExtractOAuth2(config OAuth2Config, client *http.Client, authURLOpts map[str
if code == "" {
// If the code isn't provided, we'll redirect!
state, err := cryptorand.String(32)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error generating state string.",
Detail: err.Error(),
})
return
var state string
// If this url param is provided, then a user is trying to merge
// their account with an OIDC account. Their password would have
// been required to get to this point, so we do not need to verify
// their password again.
oidcMergeState := r.URL.Query().Get("oidc_merge_state")
if oidcMergeState != "" {
state = oidcMergeState
} else {
var err error
state, err = cryptorand.String(32)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error generating state string.",
Detail: err.Error(),
})
return
}
}
http.SetCookie(rw, &http.Cookie{
@ -158,8 +170,9 @@ func ExtractOAuth2(config OAuth2Config, client *http.Client, authURLOpts map[str
}
ctx = context.WithValue(ctx, oauth2StateKey{}, OAuth2State{
Token: oauthToken,
Redirect: redirect,
Token: oauthToken,
Redirect: redirect,
StateString: state,
})
next.ServeHTTP(rw, r.WithContext(ctx))
})

View File

@ -7,6 +7,7 @@ import (
"net/url"
"testing"
"github.com/moby/moby/pkg/namesgenerator"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/oauth2"
@ -125,4 +126,21 @@ func TestOAuth2(t *testing.T) {
// testOAuth2Provider does this job for us.
require.NotEmpty(t, location)
})
t.Run("PresetConvertState", func(t *testing.T) {
t.Parallel()
customState := namesgenerator.GetRandomName(1)
req := httptest.NewRequest("GET", "/?oidc_merge_state="+customState+"&redirect="+url.QueryEscape("/dashboard"), nil)
res := httptest.NewRecorder()
tp := newTestOAuth2Provider(t, oauth2.AccessTypeOffline)
httpmw.ExtractOAuth2(tp, nil, nil)(nil).ServeHTTP(res, req)
found := false
for _, cookie := range res.Result().Cookies() {
if cookie.Name == codersdk.OAuth2StateCookie {
require.Equal(t, cookie.Value, customState, "expected state")
found = true
}
}
require.True(t, found, "expected state cookie")
})
}

View File

@ -10,8 +10,11 @@ import (
"sort"
"strconv"
"strings"
"sync"
"time"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/golang-jwt/jwt/v4"
"github.com/google/go-github/v43/github"
"github.com/google/uuid"
"github.com/moby/moby/pkg/namesgenerator"
@ -28,12 +31,174 @@ import (
"github.com/coder/coder/coderd/rbac"
"github.com/coder/coder/coderd/userpassword"
"github.com/coder/coder/codersdk"
"github.com/coder/coder/cryptorand"
)
const (
userAuthLoggerName = "userauth"
userAuthLoggerName = "userauth"
OAuthConvertCookieValue = "coder_oauth_convert_jwt"
mergeStateStringPrefix = "convert-"
)
type OAuthConvertStateClaims struct {
jwt.RegisteredClaims
UserID uuid.UUID `json:"user_id"`
State string `json:"state"`
FromLoginType codersdk.LoginType `json:"from_login_type"`
ToLoginType codersdk.LoginType `json:"to_login_type"`
}
// postConvertLoginType replies with an oauth state token capable of converting
// the user to an oauth user.
//
// @Summary Convert user from password to oauth authentication
// @ID convert-user-from-password-to-oauth-authentication
// @Security CoderSessionToken
// @Accept json
// @Produce json
// @Tags Authorization
// @Param request body codersdk.ConvertLoginRequest true "Convert request"
// @Param user path string true "User ID, name, or me"
// @Success 201 {object} codersdk.OAuthConversionResponse
// @Router /users/{user}/convert-login [post]
func (api *API) postConvertLoginType(rw http.ResponseWriter, r *http.Request) {
if !api.Experiments.Enabled(codersdk.ExperimentConvertToOIDC) {
httpapi.Write(r.Context(), rw, http.StatusForbidden, codersdk.Response{
Message: "Oauth conversion is not allowed, contact an administrator to turn on this feature.",
})
return
}
var (
user = httpmw.UserParam(r)
ctx = r.Context()
auditor = api.Auditor.Load()
aReq, commitAudit = audit.InitRequest[database.AuditOAuthConvertState](rw, &audit.RequestParams{
Audit: *auditor,
Log: api.Logger,
Request: r,
Action: database.AuditActionCreate,
})
)
aReq.Old = database.AuditOAuthConvertState{}
defer commitAudit()
var req codersdk.ConvertLoginRequest
if !httpapi.Read(ctx, rw, r, &req) {
return
}
switch req.ToType {
case codersdk.LoginTypeGithub, codersdk.LoginTypeOIDC:
// Allowed!
case codersdk.LoginTypeNone, codersdk.LoginTypePassword, codersdk.LoginTypeToken:
// These login types are not allowed to be converted to at this time.
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: fmt.Sprintf("Cannot convert to login type %q.", req.ToType),
})
return
default:
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: fmt.Sprintf("Unknown login type %q.", req.ToType),
})
return
}
// This handles the email/pass checking.
user, _, ok := api.loginRequest(ctx, rw, codersdk.LoginWithPasswordRequest{
Email: user.Email,
Password: req.Password,
})
if !ok {
return
}
// Only support converting from password auth.
if user.LoginType != database.LoginTypePassword {
// This is checked in loginRequest, but checked again here in case that shared
// function changes its checks. Just some defensive programming.
// This login type is **required** to be password based to prevent
// users from converting other login types to OIDC.
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "User account must have password based authentication.",
})
return
}
stateString, err := cryptorand.String(32)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error generating state string.",
Detail: err.Error(),
})
return
}
// The prefix is used to identify this state string as a conversion state
// without needing to hit the database. The random string is the CSRF protection.
stateString = fmt.Sprintf("%s%s", mergeStateStringPrefix, stateString)
// This JWT is the signed payload to authorize the convert to oauth request.
// When the user does the oauth flow, this jwt will be sent back to coderd.
// The included information in this payload links it to a state string, so
// this request is tied 1:1 with an oauth state.
// This JWT also includes information to tie it 1:1 with a coder deployment
// and user account. This is mainly to inform the user if they are accidentally
// switching between coder deployments if the OIDC is misconfigured.
// Eg: Developers with more than 1 deployment.
now := time.Now()
claims := &OAuthConvertStateClaims{
RegisteredClaims: jwt.RegisteredClaims{
Issuer: api.DeploymentID,
Subject: stateString,
Audience: []string{user.ID.String()},
ExpiresAt: jwt.NewNumericDate(now.Add(time.Minute * 5)),
NotBefore: jwt.NewNumericDate(now.Add(time.Second * -1)),
IssuedAt: jwt.NewNumericDate(now),
ID: uuid.NewString(),
},
UserID: user.ID,
State: stateString,
FromLoginType: codersdk.LoginType(user.LoginType),
ToLoginType: req.ToType,
}
token := jwt.NewWithClaims(jwt.SigningMethodHS512, claims)
// Key must be a byte slice, not an array. So make sure to include the [:]
tokenString, err := token.SignedString(api.OAuthSigningKey[:])
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error signing state jwt.",
Detail: err.Error(),
})
return
}
aReq.New = database.AuditOAuthConvertState{
CreatedAt: claims.IssuedAt.Time,
ExpiresAt: claims.ExpiresAt.Time,
FromLoginType: database.LoginType(claims.FromLoginType),
ToLoginType: database.LoginType(claims.ToLoginType),
UserID: claims.UserID,
}
http.SetCookie(rw, &http.Cookie{
Name: OAuthConvertCookieValue,
Path: "/",
Value: tokenString,
Expires: claims.ExpiresAt.Time,
Secure: api.SecureAuthCookie,
HttpOnly: true,
SameSite: http.SameSiteStrictMode,
})
httpapi.Write(ctx, rw, http.StatusCreated, codersdk.OAuthConversionResponse{
StateString: stateString,
ExpiresAt: claims.ExpiresAt.Time,
ToType: claims.ToLoginType,
UserID: claims.UserID,
})
}
// Authenticates the user with an email and password.
//
// @Summary Log in user
@ -48,6 +213,7 @@ func (api *API) postLogin(rw http.ResponseWriter, r *http.Request) {
var (
ctx = r.Context()
auditor = api.Auditor.Load()
logger = api.Logger.Named(userAuthLoggerName)
aReq, commitAudit = audit.InitRequest[database.APIKey](rw, &audit.RequestParams{
Audit: *auditor,
Log: api.Logger,
@ -63,71 +229,12 @@ func (api *API) postLogin(rw http.ResponseWriter, r *http.Request) {
return
}
logger := api.Logger.Named(userAuthLoggerName)
//nolint:gocritic // In order to login, we need to get the user first!
user, err := api.Database.GetUserByEmailOrUsername(dbauthz.AsSystemRestricted(ctx), database.GetUserByEmailOrUsernameParams{
Email: loginWithPassword.Email,
})
if err != nil && !xerrors.Is(err, sql.ErrNoRows) {
logger.Error(ctx, "unable to fetch user by email", slog.Error(err))
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error.",
})
return
}
user, roles, ok := api.loginRequest(ctx, rw, loginWithPassword)
// 'user.ID' will be empty, or will be an actual value. Either is correct
// here.
aReq.UserID = user.ID
// If the user doesn't exist, it will be a default struct.
equal, err := userpassword.Compare(string(user.HashedPassword), loginWithPassword.Password)
if err != nil {
logger.Error(ctx, "unable to compare passwords", slog.Error(err))
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error.",
})
return
}
if !equal {
// This message is the same as above to remove ease in detecting whether
// users are registered or not. Attackers still could with a timing attack.
httpapi.Write(ctx, rw, http.StatusUnauthorized, codersdk.Response{
Message: "Incorrect email or password.",
})
return
}
// If password authentication is disabled and the user does not have the
// owner role, block the request.
if api.DeploymentValues.DisablePasswordAuth {
httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{
Message: "Password authentication is disabled.",
})
return
}
if user.LoginType != database.LoginTypePassword {
httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{
Message: fmt.Sprintf("Incorrect login type, attempting to use %q but user is of login type %q", database.LoginTypePassword, user.LoginType),
})
return
}
//nolint:gocritic // System needs to fetch user roles in order to login user.
roles, err := api.Database.GetAuthorizationUserRoles(dbauthz.AsSystemRestricted(ctx), user.ID)
if err != nil {
logger.Error(ctx, "unable to fetch authorization user roles", slog.Error(err))
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error.",
})
return
}
// If the user logged into a suspended account, reject the login request.
if roles.Status != database.UserStatusActive {
httpapi.Write(ctx, rw, http.StatusUnauthorized, codersdk.Response{
Message: "Your account is suspended. Contact an admin to reactivate your account.",
})
if !ok {
// user failed to login
return
}
@ -163,6 +270,83 @@ func (api *API) postLogin(rw http.ResponseWriter, r *http.Request) {
})
}
// loginRequest will process a LoginWithPasswordRequest and return the user if
// the credentials are correct. If 'false' is returned, the authentication failed
// and the appropriate error will be written to the ResponseWriter.
//
// The user struct is always returned, even if authentication failed. This is
// to support knowing what user attempted to login.
func (api *API) loginRequest(ctx context.Context, rw http.ResponseWriter, req codersdk.LoginWithPasswordRequest) (database.User, database.GetAuthorizationUserRolesRow, bool) {
logger := api.Logger.Named(userAuthLoggerName)
//nolint:gocritic // In order to login, we need to get the user first!
user, err := api.Database.GetUserByEmailOrUsername(dbauthz.AsSystemRestricted(ctx), database.GetUserByEmailOrUsernameParams{
Email: req.Email,
})
if err != nil && !xerrors.Is(err, sql.ErrNoRows) {
logger.Error(ctx, "unable to fetch user by email", slog.Error(err))
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error.",
})
return user, database.GetAuthorizationUserRolesRow{}, false
}
// If the user doesn't exist, it will be a default struct.
equal, err := userpassword.Compare(string(user.HashedPassword), req.Password)
if err != nil {
logger.Error(ctx, "unable to compare passwords", slog.Error(err))
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error.",
})
return user, database.GetAuthorizationUserRolesRow{}, false
}
if !equal {
// This message is the same as above to remove ease in detecting whether
// users are registered or not. Attackers still could with a timing attack.
httpapi.Write(ctx, rw, http.StatusUnauthorized, codersdk.Response{
Message: "Incorrect email or password.",
})
return user, database.GetAuthorizationUserRolesRow{}, false
}
// If password authentication is disabled and the user does not have the
// owner role, block the request.
if api.DeploymentValues.DisablePasswordAuth {
httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{
Message: "Password authentication is disabled.",
})
return user, database.GetAuthorizationUserRolesRow{}, false
}
if user.LoginType != database.LoginTypePassword {
httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{
Message: fmt.Sprintf("Incorrect login type, attempting to use %q but user is of login type %q", database.LoginTypePassword, user.LoginType),
})
return user, database.GetAuthorizationUserRolesRow{}, false
}
//nolint:gocritic // System needs to fetch user roles in order to login user.
roles, err := api.Database.GetAuthorizationUserRoles(dbauthz.AsSystemRestricted(ctx), user.ID)
if err != nil {
logger.Error(ctx, "unable to fetch authorization user roles", slog.Error(err))
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error.",
})
return user, database.GetAuthorizationUserRolesRow{}, false
}
// If the user logged into a suspended account, reject the login request.
if roles.Status != database.UserStatusActive {
httpapi.Write(ctx, rw, http.StatusUnauthorized, codersdk.Response{
Message: "Your account is suspended. Contact an admin to reactivate your account.",
})
return user, database.GetAuthorizationUserRolesRow{}, false
}
return user, roles, true
}
// Clear the user's session cookie.
//
// @Summary Log out user
@ -270,6 +454,7 @@ func (api *API) userAuthMethods(rw http.ResponseWriter, r *http.Request) {
}
httpapi.Write(r.Context(), rw, http.StatusOK, codersdk.AuthMethods{
ConvertToOIDCEnabled: api.Experiments.Enabled(codersdk.ExperimentConvertToOIDC),
Password: codersdk.AuthMethod{
Enabled: !api.DeploymentValues.DisablePasswordAuth.Value(),
},
@ -423,7 +608,7 @@ func (api *API) userOAuth2Github(rw http.ResponseWriter, r *http.Request) {
aReq.Action = database.AuditActionRegister
}
cookie, key, err := api.oauthLogin(r, oauthLoginParams{
params := (&oauthLoginParams{
User: user,
Link: link,
State: state,
@ -433,7 +618,11 @@ func (api *API) userOAuth2Github(rw http.ResponseWriter, r *http.Request) {
Email: verifiedEmail.GetEmail(),
Username: ghUser.GetLogin(),
AvatarURL: ghUser.GetAvatarURL(),
}).SetInitAuditRequest(func(params *audit.RequestParams) (*audit.Request[database.User], func()) {
return audit.InitRequest[database.User](rw, params)
})
cookies, key, err := api.oauthLogin(r, params)
defer params.CommitAuditLogs()
var httpErr httpError
if xerrors.As(err, &httpErr) {
httpapi.Write(ctx, rw, httpErr.code, codersdk.Response{
@ -453,7 +642,9 @@ func (api *API) userOAuth2Github(rw http.ResponseWriter, r *http.Request) {
aReq.New = key
aReq.UserID = key.UserID
http.SetCookie(rw, cookie)
for _, cookie := range cookies {
http.SetCookie(rw, cookie)
}
redirect := state.Redirect
if redirect == "" {
@ -673,7 +864,7 @@ func (api *API) userOIDC(rw http.ResponseWriter, r *http.Request) {
// Convert the []interface{} we get to a []string.
groupsInterface, ok := groupsRaw.([]interface{})
if ok {
logger.Debug(ctx, "groups returned in oidc claims",
api.Logger.Debug(ctx, "groups returned in oidc claims",
slog.F("len", len(groupsInterface)),
slog.F("groups", groupsInterface),
)
@ -694,7 +885,7 @@ func (api *API) userOIDC(rw http.ResponseWriter, r *http.Request) {
groups = append(groups, group)
}
} else {
logger.Debug(ctx, "groups field was an unknown type",
api.Logger.Debug(ctx, "groups field was an unknown type",
slog.F("type", fmt.Sprintf("%T", groupsRaw)),
)
}
@ -759,7 +950,7 @@ func (api *API) userOIDC(rw http.ResponseWriter, r *http.Request) {
aReq.Action = database.AuditActionRegister
}
cookie, key, err := api.oauthLogin(r, oauthLoginParams{
params := (&oauthLoginParams{
User: user,
Link: link,
State: state,
@ -771,7 +962,11 @@ func (api *API) userOIDC(rw http.ResponseWriter, r *http.Request) {
AvatarURL: picture,
UsingGroups: usingGroups,
Groups: groups,
}).SetInitAuditRequest(func(params *audit.RequestParams) (*audit.Request[database.User], func()) {
return audit.InitRequest[database.User](rw, params)
})
cookies, key, err := api.oauthLogin(r, params)
defer params.CommitAuditLogs()
var httpErr httpError
if xerrors.As(err, &httpErr) {
httpapi.Write(ctx, rw, httpErr.code, codersdk.Response{
@ -791,7 +986,9 @@ func (api *API) userOIDC(rw http.ResponseWriter, r *http.Request) {
aReq.New = key
aReq.UserID = key.UserID
http.SetCookie(rw, cookie)
for i := range cookies {
http.SetCookie(rw, cookies[i])
}
redirect := state.Redirect
if redirect == "" {
@ -853,6 +1050,29 @@ type oauthLoginParams struct {
// to the Groups provided.
UsingGroups bool
Groups []string
commitLock sync.Mutex
initAuditRequest func(params *audit.RequestParams) *audit.Request[database.User]
commits []func()
}
func (p *oauthLoginParams) SetInitAuditRequest(f func(params *audit.RequestParams) (*audit.Request[database.User], func())) *oauthLoginParams {
p.initAuditRequest = func(params *audit.RequestParams) *audit.Request[database.User] {
p.commitLock.Lock()
defer p.commitLock.Unlock()
req, commit := f(params)
p.commits = append(p.commits, commit)
return req
}
return p
}
func (p *oauthLoginParams) CommitAuditLogs() {
p.commitLock.Lock()
defer p.commitLock.Unlock()
for _, f := range p.commits {
f()
}
}
type httpError struct {
@ -869,10 +1089,11 @@ func (e httpError) Error() string {
return e.msg
}
func (api *API) oauthLogin(r *http.Request, params oauthLoginParams) (*http.Cookie, database.APIKey, error) {
func (api *API) oauthLogin(r *http.Request, params *oauthLoginParams) ([]*http.Cookie, database.APIKey, error) {
var (
ctx = r.Context()
user database.User
ctx = r.Context()
user database.User
cookies []*http.Cookie
)
err := api.Database.InTx(func(tx database.Store) error {
@ -884,6 +1105,19 @@ func (api *API) oauthLogin(r *http.Request, params oauthLoginParams) (*http.Cook
user = params.User
link = params.Link
// If you do a convert to OIDC and your email does not match, we need to
// catch this and not make a new account.
if isMergeStateString(params.State.StateString) {
// Always clear this cookie. If it succeeds, we no longer need it.
// If it fails, we no longer care about it.
cookies = append(cookies, clearOAuthConvertCookie())
user, err = api.convertUserToOauth(ctx, r, tx, params)
if err != nil {
return err
}
params.User = user
}
if user.ID == uuid.Nil && !params.AllowSignups {
return httpError{
code: http.StatusForbidden,
@ -1061,7 +1295,118 @@ func (api *API) oauthLogin(r *http.Request, params oauthLoginParams) (*http.Cook
return nil, database.APIKey{}, xerrors.Errorf("create API key: %w", err)
}
return cookie, *key, nil
return append(cookies, cookie), *key, nil
}
// convertUserToOauth will convert a user from password base loginType to
// an oauth login type. If it fails, it will return a httpError
func (api *API) convertUserToOauth(ctx context.Context, r *http.Request, db database.Store, params *oauthLoginParams) (database.User, error) {
user := params.User
// Trying to convert to OIDC, but the email does not match.
// So do not make a new user, just block the request.
if user.ID == uuid.Nil {
return database.User{}, httpError{
code: http.StatusBadRequest,
msg: fmt.Sprintf("The oidc account with the email %q does not match the email of the account you are trying to convert. Contact your administrator to resolve this issue.", params.Email),
}
}
jwtCookie, err := r.Cookie(OAuthConvertCookieValue)
if err != nil {
return database.User{}, httpError{
code: http.StatusBadRequest,
msg: fmt.Sprintf("Convert to oauth cookie not found. Missing signed jwt to authorize this action. " +
"Please try again."),
}
}
var claims OAuthConvertStateClaims
token, err := jwt.ParseWithClaims(jwtCookie.Value, &claims, func(token *jwt.Token) (interface{}, error) {
return api.OAuthSigningKey[:], nil
})
if xerrors.Is(err, jwt.ErrSignatureInvalid) || !token.Valid {
// These errors are probably because the user is mixing 2 coder deployments.
return database.User{}, httpError{
code: http.StatusBadRequest,
msg: "Using an invalid jwt to authorize this action. Ensure there is only 1 coder deployment and try again.",
}
}
if err != nil {
return database.User{}, httpError{
code: http.StatusInternalServerError,
msg: fmt.Sprintf("Error parsing jwt: %v", err),
}
}
// At this point, this request could be an attempt to convert from
// password auth to oauth auth. Always log these attempts.
var (
auditor = *api.Auditor.Load()
oauthConvertAudit = params.initAuditRequest(&audit.RequestParams{
Audit: auditor,
Log: api.Logger,
Request: r,
Action: database.AuditActionWrite,
})
)
oauthConvertAudit.UserID = claims.UserID
oauthConvertAudit.Old = user
// If we do not allow converting to oauth, return an error.
if !api.Experiments.Enabled(codersdk.ExperimentConvertToOIDC) {
return database.User{}, httpError{
code: http.StatusForbidden,
msg: fmt.Sprintf("Incorrect login type, attempting to use %q but user is of login type %q",
params.LoginType,
user.LoginType,
),
}
}
if claims.RegisteredClaims.Issuer != api.DeploymentID {
return database.User{}, httpError{
code: http.StatusForbidden,
msg: "Request to convert login type failed. Issuer mismatch. Found a cookie from another coder deployment, please try again.",
}
}
if params.State.StateString != claims.State {
return database.User{}, httpError{
code: http.StatusForbidden,
msg: "Request to convert login type failed. State mismatch.",
}
}
// Make sure the merge state generated matches this OIDC login request.
// It needs to have the correct login type information for this
// user.
if user.ID != claims.UserID ||
codersdk.LoginType(user.LoginType) != claims.FromLoginType ||
codersdk.LoginType(params.LoginType) != claims.ToLoginType {
return database.User{}, httpError{
code: http.StatusForbidden,
msg: fmt.Sprintf("Request to convert login type from %s to %s failed", user.LoginType, params.LoginType),
}
}
// Convert the user and default to the normal login flow.
// If the login succeeds, this transaction will commit and the user
// will be converted.
// nolint:gocritic // system query to update user login type. The user already
// provided their password to authenticate this request.
user, err = db.UpdateUserLoginType(dbauthz.AsSystemRestricted(ctx), database.UpdateUserLoginTypeParams{
NewLoginType: params.LoginType,
UserID: user.ID,
})
if err != nil {
return database.User{}, httpError{
code: http.StatusInternalServerError,
msg: "Failed to convert user to new login type",
}
}
oauthConvertAudit.New = user
return user, nil
}
// githubLinkedID returns the unique ID for a GitHub user.
@ -1130,3 +1475,15 @@ func findLinkedUser(ctx context.Context, db database.Store, linkedID string, ema
return user, link, nil
}
func isMergeStateString(state string) bool {
return strings.HasPrefix(state, mergeStateStringPrefix)
}
func clearOAuthConvertCookie() *http.Cookie {
return &http.Cookie{
Name: OAuthConvertCookieValue,
Path: "/",
MaxAge: -1,
}
}

View File

@ -6,6 +6,7 @@ import (
"fmt"
"io"
"net/http"
"net/http/cookiejar"
"strings"
"testing"
@ -19,7 +20,7 @@ import (
"golang.org/x/xerrors"
"cdr.dev/slog/sloggers/slogtest"
"github.com/coder/coder/cli/clibase"
"github.com/coder/coder/coderd"
"github.com/coder/coder/coderd/audit"
"github.com/coder/coder/coderd/coderdtest"
@ -786,6 +787,44 @@ func TestUserOIDC(t *testing.T) {
})
}
t.Run("OIDCConvert", func(t *testing.T) {
t.Parallel()
auditor := audit.NewMock()
conf := coderdtest.NewOIDCConfig(t, "")
config := conf.OIDCConfig(t, nil)
config.AllowSignups = true
cfg := coderdtest.DeploymentValues(t)
cfg.Experiments = clibase.StringArray{string(codersdk.ExperimentConvertToOIDC)}
client := coderdtest.New(t, &coderdtest.Options{
Auditor: auditor,
OIDCConfig: config,
DeploymentValues: cfg,
})
owner := coderdtest.CreateFirstUser(t, client)
user, userData := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
code := conf.EncodeClaims(t, jwt.MapClaims{
"email": userData.Email,
})
var err error
user.HTTPClient.Jar, err = cookiejar.New(nil)
require.NoError(t, err)
ctx := testutil.Context(t, testutil.WaitShort)
convertResponse, err := user.ConvertLoginType(ctx, codersdk.ConvertLoginRequest{
ToType: codersdk.LoginTypeOIDC,
Password: "SomeSecurePassword!",
})
require.NoError(t, err)
resp := oidcCallbackWithState(t, user, code, convertResponse.StateString)
require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode)
})
t.Run("AlternateUsername", func(t *testing.T) {
t.Parallel()
auditor := audit.NewMock()
@ -1008,17 +1047,22 @@ func oauth2Callback(t *testing.T, client *codersdk.Client) *http.Response {
}
func oidcCallback(t *testing.T, client *codersdk.Client, code string) *http.Response {
return oidcCallbackWithState(t, client, code, "somestate")
}
func oidcCallbackWithState(t *testing.T, client *codersdk.Client, code, state string) *http.Response {
t.Helper()
client.HTTPClient.CheckRedirect = func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
}
oauthURL, err := client.URL.Parse(fmt.Sprintf("/api/v2/users/oidc/callback?code=%s&state=somestate", code))
oauthURL, err := client.URL.Parse(fmt.Sprintf("/api/v2/users/oidc/callback?code=%s&state=%s", code, state))
require.NoError(t, err)
req, err := http.NewRequestWithContext(context.Background(), "GET", oauthURL.String(), nil)
require.NoError(t, err)
req.AddCookie(&http.Cookie{
Name: codersdk.OAuth2StateCookie,
Value: "somestate",
Value: state,
})
res, err := client.HTTPClient.Do(req)
require.NoError(t, err)

View File

@ -501,6 +501,37 @@ func (api *API) userByName(rw http.ResponseWriter, r *http.Request) {
httpapi.Write(ctx, rw, http.StatusOK, db2sdk.User(user, organizationIDs))
}
// Returns the user's login type. This only works if the api key for authorization
// and the requested user match. Eg: 'me'
//
// @Summary Get user login type
// @ID get-user-login-type
// @Security CoderSessionToken
// @Produce json
// @Tags Users
// @Param user path string true "User ID, name, or me"
// @Success 200 {object} codersdk.UserLoginType
// @Router /users/{user}/login-type [get]
func (*API) userLoginType(rw http.ResponseWriter, r *http.Request) {
var (
ctx = r.Context()
user = httpmw.UserParam(r)
key = httpmw.APIKey(r)
)
if key.UserID != user.ID {
// Currently this is only valid for querying yourself.
httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{
Message: "You are not authorized to view this user's login type.",
})
return
}
httpapi.Write(ctx, rw, http.StatusOK, codersdk.UserLoginType{
LoginType: codersdk.LoginType(user.LoginType),
})
}
// @Summary Update user profile
// @ID update-user-profile
// @Security CoderSessionToken

View File

@ -23,6 +23,7 @@ const (
ResourceTypeAPIKey ResourceType = "api_key"
ResourceTypeGroup ResourceType = "group"
ResourceTypeLicense ResourceType = "license"
ResourceTypeConvertLogin ResourceType = "convert_login"
)
func (r ResourceType) FriendlyString() string {
@ -47,6 +48,8 @@ func (r ResourceType) FriendlyString() string {
return "group"
case ResourceTypeLicense:
return "license"
case ResourceTypeConvertLogin:
return "login type conversion"
default:
return "unknown"
}

View File

@ -1759,6 +1759,10 @@ const (
// only Coordinator
ExperimentTailnetPGCoordinator Experiment = "tailnet_pg_coordinator"
// ExperimentConvertToOIDC enables users to convert from password to
// oidc.
ExperimentConvertToOIDC Experiment = "convert-to-oidc"
// Add new experiments here!
// ExperimentExample Experiment = "example"
)

View File

@ -93,6 +93,12 @@ type UserRoles struct {
OrganizationRoles map[uuid.UUID][]string `json:"organization_roles"`
}
type ConvertLoginRequest struct {
// ToType is the login type to convert to.
ToType LoginType `json:"to_type" validate:"required"`
Password string `json:"password" validate:"required"`
}
// LoginWithPasswordRequest enables callers to authenticate with email and password.
type LoginWithPasswordRequest struct {
Email string `json:"email" validate:"required,email" format:"email"`
@ -104,21 +110,33 @@ type LoginWithPasswordResponse struct {
SessionToken string `json:"session_token" validate:"required"`
}
type OAuthConversionResponse struct {
StateString string `json:"state_string"`
ExpiresAt time.Time `json:"expires_at" format:"date-time"`
ToType LoginType `json:"to_type"`
UserID uuid.UUID `json:"user_id" format:"uuid"`
}
type CreateOrganizationRequest struct {
Name string `json:"name" validate:"required,username"`
}
// AuthMethods contains authentication method information like whether they are enabled or not or custom text, etc.
type AuthMethods struct {
Password AuthMethod `json:"password"`
Github AuthMethod `json:"github"`
OIDC OIDCAuthMethod `json:"oidc"`
ConvertToOIDCEnabled bool `json:"convert_to_oidc_enabled"`
Password AuthMethod `json:"password"`
Github AuthMethod `json:"github"`
OIDC OIDCAuthMethod `json:"oidc"`
}
type AuthMethod struct {
Enabled bool `json:"enabled"`
}
type UserLoginType struct {
LoginType LoginType `json:"login_type"`
}
type OIDCAuthMethod struct {
AuthMethod
SignInText string `json:"signInText"`
@ -299,6 +317,26 @@ func (c *Client) LoginWithPassword(ctx context.Context, req LoginWithPasswordReq
return resp, nil
}
// ConvertLoginType will send a request to convert the user from password
// based authentication to oauth based. The response has the oauth state code
// to use in the oauth flow.
func (c *Client) ConvertLoginType(ctx context.Context, req ConvertLoginRequest) (OAuthConversionResponse, error) {
res, err := c.Request(ctx, http.MethodPost, "/api/v2/users/me/convert-login", req)
if err != nil {
return OAuthConversionResponse{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusCreated {
return OAuthConversionResponse{}, ReadBodyAsError(res)
}
var resp OAuthConversionResponse
err = json.NewDecoder(res.Body).Decode(&resp)
if err != nil {
return OAuthConversionResponse{}, err
}
return resp, nil
}
// Logout calls the /logout API
// Call `ClearSessionToken()` to clear the session token of the client.
func (c *Client) Logout(ctx context.Context) error {

View File

@ -12,12 +12,13 @@ We track the following resources:
| <b>Resource<b> | |
| -------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| APIKey<br><i>login, logout, register, create, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>created_at</td><td>true</td></tr><tr><td>expires_at</td><td>true</td></tr><tr><td>hashed_secret</td><td>false</td></tr><tr><td>id</td><td>false</td></tr><tr><td>ip_address</td><td>false</td></tr><tr><td>last_used</td><td>true</td></tr><tr><td>lifetime_seconds</td><td>false</td></tr><tr><td>login_type</td><td>false</td></tr><tr><td>scope</td><td>false</td></tr><tr><td>token_name</td><td>false</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>user_id</td><td>true</td></tr></tbody></table> |
| AuditOAuthConvertState<br><i></i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>created_at</td><td>true</td></tr><tr><td>expires_at</td><td>true</td></tr><tr><td>from_login_type</td><td>true</td></tr><tr><td>to_login_type</td><td>true</td></tr><tr><td>user_id</td><td>true</td></tr></tbody></table> |
| Group<br><i>create, write, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>avatar_url</td><td>true</td></tr><tr><td>id</td><td>true</td></tr><tr><td>members</td><td>true</td></tr><tr><td>name</td><td>true</td></tr><tr><td>organization_id</td><td>false</td></tr><tr><td>quota_allowance</td><td>true</td></tr></tbody></table> |
| GitSSHKey<br><i>create</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>created_at</td><td>false</td></tr><tr><td>private_key</td><td>true</td></tr><tr><td>public_key</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>user_id</td><td>true</td></tr></tbody></table> |
| License<br><i>create, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>exp</td><td>true</td></tr><tr><td>id</td><td>false</td></tr><tr><td>jwt</td><td>false</td></tr><tr><td>uploaded_at</td><td>true</td></tr><tr><td>uuid</td><td>true</td></tr></tbody></table> |
| Template<br><i>write, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>active_version_id</td><td>true</td></tr><tr><td>allow_user_autostart</td><td>true</td></tr><tr><td>allow_user_autostop</td><td>true</td></tr><tr><td>allow_user_cancel_workspace_jobs</td><td>true</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>created_by</td><td>true</td></tr><tr><td>default_ttl</td><td>true</td></tr><tr><td>deleted</td><td>false</td></tr><tr><td>description</td><td>true</td></tr><tr><td>display_name</td><td>true</td></tr><tr><td>failure_ttl</td><td>true</td></tr><tr><td>group_acl</td><td>true</td></tr><tr><td>icon</td><td>true</td></tr><tr><td>id</td><td>true</td></tr><tr><td>inactivity_ttl</td><td>true</td></tr><tr><td>locked_ttl</td><td>true</td></tr><tr><td>max_ttl</td><td>true</td></tr><tr><td>name</td><td>true</td></tr><tr><td>organization_id</td><td>false</td></tr><tr><td>provisioner</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>user_acl</td><td>true</td></tr></tbody></table> |
| TemplateVersion<br><i>create, write</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>created_at</td><td>false</td></tr><tr><td>created_by</td><td>true</td></tr><tr><td>git_auth_providers</td><td>false</td></tr><tr><td>id</td><td>true</td></tr><tr><td>job_id</td><td>false</td></tr><tr><td>name</td><td>true</td></tr><tr><td>organization_id</td><td>false</td></tr><tr><td>readme</td><td>true</td></tr><tr><td>template_id</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr></tbody></table> |
| User<br><i>create, write, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>avatar_url</td><td>false</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>deleted</td><td>true</td></tr><tr><td>email</td><td>true</td></tr><tr><td>hashed_password</td><td>true</td></tr><tr><td>id</td><td>true</td></tr><tr><td>last_seen_at</td><td>false</td></tr><tr><td>login_type</td><td>false</td></tr><tr><td>rbac_roles</td><td>true</td></tr><tr><td>status</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>username</td><td>true</td></tr></tbody></table> |
| User<br><i>create, write, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>avatar_url</td><td>false</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>deleted</td><td>true</td></tr><tr><td>email</td><td>true</td></tr><tr><td>hashed_password</td><td>true</td></tr><tr><td>id</td><td>true</td></tr><tr><td>last_seen_at</td><td>false</td></tr><tr><td>login_type</td><td>true</td></tr><tr><td>rbac_roles</td><td>true</td></tr><tr><td>status</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>username</td><td>true</td></tr></tbody></table> |
| Workspace<br><i>create, write, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>autostart_schedule</td><td>true</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>deleted</td><td>false</td></tr><tr><td>id</td><td>true</td></tr><tr><td>last_used_at</td><td>false</td></tr><tr><td>locked_at</td><td>true</td></tr><tr><td>name</td><td>true</td></tr><tr><td>organization_id</td><td>false</td></tr><tr><td>owner_id</td><td>true</td></tr><tr><td>template_id</td><td>true</td></tr><tr><td>ttl</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr></tbody></table> |
| WorkspaceBuild<br><i>start, stop</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>build_number</td><td>false</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>daily_cost</td><td>false</td></tr><tr><td>deadline</td><td>false</td></tr><tr><td>id</td><td>false</td></tr><tr><td>initiator_id</td><td>false</td></tr><tr><td>job_id</td><td>false</td></tr><tr><td>max_deadline</td><td>false</td></tr><tr><td>provisioner_state</td><td>false</td></tr><tr><td>reason</td><td>false</td></tr><tr><td>template_version_id</td><td>true</td></tr><tr><td>transition</td><td>false</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>workspace_id</td><td>false</td></tr></tbody></table> |
| WorkspaceProxy<br><i></i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>created_at</td><td>true</td></tr><tr><td>deleted</td><td>false</td></tr><tr><td>display_name</td><td>true</td></tr><tr><td>icon</td><td>true</td></tr><tr><td>id</td><td>true</td></tr><tr><td>name</td><td>true</td></tr><tr><td>token_hashed_secret</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>url</td><td>true</td></tr><tr><td>wildcard_hostname</td><td>true</td></tr></tbody></table> |

View File

@ -109,3 +109,54 @@ curl -X POST http://coder-server:8080/api/v2/users/login \
| Status | Meaning | Description | Schema |
| ------ | ------------------------------------------------------------ | ----------- | ---------------------------------------------------------------------------------- |
| 201 | [Created](https://tools.ietf.org/html/rfc7231#section-6.3.2) | Created | [codersdk.LoginWithPasswordResponse](schemas.md#codersdkloginwithpasswordresponse) |
## Convert user from password to oauth authentication
### Code samples
```shell
# Example request using curl
curl -X POST http://coder-server:8080/api/v2/users/{user}/convert-login \
-H 'Content-Type: application/json' \
-H 'Accept: application/json' \
-H 'Coder-Session-Token: API_KEY'
```
`POST /users/{user}/convert-login`
> Body parameter
```json
{
"password": "string",
"to_type": "password"
}
```
### Parameters
| Name | In | Type | Required | Description |
| ------ | ---- | ---------------------------------------------------------------------- | -------- | -------------------- |
| `user` | path | string | true | User ID, name, or me |
| `body` | body | [codersdk.ConvertLoginRequest](schemas.md#codersdkconvertloginrequest) | true | Convert request |
### Example responses
> 201 Response
```json
{
"expires_at": "2019-08-24T14:15:22Z",
"state_string": "string",
"to_type": "password",
"user_id": "a169451c-8525-4352-b8ca-070dd449a1a5"
}
```
### Responses
| Status | Meaning | Description | Schema |
| ------ | ------------------------------------------------------------ | ----------- | ------------------------------------------------------------------------------ |
| 201 | [Created](https://tools.ietf.org/html/rfc7231#section-6.3.2) | Created | [codersdk.OAuthConversionResponse](schemas.md#codersdkoauthconversionresponse) |
To perform this operation, you must be authenticated. [Learn more](authentication.md).

View File

@ -1113,6 +1113,7 @@
```json
{
"convert_to_oidc_enabled": true,
"github": {
"enabled": true
},
@ -1129,11 +1130,12 @@
### Properties
| Name | Type | Required | Restrictions | Description |
| ---------- | -------------------------------------------------- | -------- | ------------ | ----------- |
| `github` | [codersdk.AuthMethod](#codersdkauthmethod) | false | | |
| `oidc` | [codersdk.OIDCAuthMethod](#codersdkoidcauthmethod) | false | | |
| `password` | [codersdk.AuthMethod](#codersdkauthmethod) | false | | |
| Name | Type | Required | Restrictions | Description |
| ------------------------- | -------------------------------------------------- | -------- | ------------ | ----------- |
| `convert_to_oidc_enabled` | boolean | false | | |
| `github` | [codersdk.AuthMethod](#codersdkauthmethod) | false | | |
| `oidc` | [codersdk.OIDCAuthMethod](#codersdkoidcauthmethod) | false | | |
| `password` | [codersdk.AuthMethod](#codersdkauthmethod) | false | | |
## codersdk.AuthorizationCheck
@ -1274,6 +1276,22 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
| `autostart` |
| `autostop` |
## codersdk.ConvertLoginRequest
```json
{
"password": "string",
"to_type": "password"
}
```
### Properties
| Name | Type | Required | Restrictions | Description |
| ---------- | ---------------------------------------- | -------- | ------------ | ---------------------------------------- |
| `password` | string | true | | |
| `to_type` | [codersdk.LoginType](#codersdklogintype) | true | | To type is the login type to convert to. |
## codersdk.CreateFirstUserRequest
```json
@ -2518,6 +2536,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
| `moons` |
| `workspace_actions` |
| `tailnet_pg_coordinator` |
| `convert-to-oidc` |
## codersdk.Feature
@ -3057,6 +3076,26 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
| `client_secret` | string | false | | |
| `enterprise_base_url` | string | false | | |
## codersdk.OAuthConversionResponse
```json
{
"expires_at": "2019-08-24T14:15:22Z",
"state_string": "string",
"to_type": "password",
"user_id": "a169451c-8525-4352-b8ca-070dd449a1a5"
}
```
### Properties
| Name | Type | Required | Restrictions | Description |
| -------------- | ---------------------------------------- | -------- | ------------ | ----------- |
| `expires_at` | string | false | | |
| `state_string` | string | false | | |
| `to_type` | [codersdk.LoginType](#codersdklogintype) | false | | |
| `user_id` | string | false | | |
## codersdk.OIDCAuthMethod
```json
@ -3638,6 +3677,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
| `api_key` |
| `group` |
| `license` |
| `convert_login` |
## codersdk.Response
@ -4527,6 +4567,20 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
| `status` | `active` |
| `status` | `suspended` |
## codersdk.UserLoginType
```json
{
"login_type": "password"
}
```
### Properties
| Name | Type | Required | Restrictions | Description |
| ------------ | ---------------------------------------- | -------- | ------------ | ----------- |
| `login_type` | [codersdk.LoginType](#codersdklogintype) | false | | |
## codersdk.UserStatus
```json

View File

@ -140,6 +140,7 @@ curl -X GET http://coder-server:8080/api/v2/users/authmethods \
```json
{
"convert_to_oidc_enabled": true,
"github": {
"enabled": true
},
@ -792,6 +793,43 @@ curl -X DELETE http://coder-server:8080/api/v2/users/{user}/keys/{keyid} \
To perform this operation, you must be authenticated. [Learn more](authentication.md).
## Get user login type
### Code samples
```shell
# Example request using curl
curl -X GET http://coder-server:8080/api/v2/users/{user}/login-type \
-H 'Accept: application/json' \
-H 'Coder-Session-Token: API_KEY'
```
`GET /users/{user}/login-type`
### Parameters
| Name | In | Type | Required | Description |
| ------ | ---- | ------ | -------- | -------------------- |
| `user` | path | string | true | User ID, name, or me |
### Example responses
> 200 Response
```json
{
"login_type": "password"
}
```
### Responses
| Status | Meaning | Description | Schema |
| ------ | ------------------------------------------------------- | ----------- | ---------------------------------------------------------- |
| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.UserLoginType](schemas.md#codersdkuserlogintype) |
To perform this operation, you must be authenticated. [Learn more](authentication.md).
## Get organizations by user
### Code samples

View File

@ -100,7 +100,7 @@ var auditableResourcesTypes = map[any]map[string]Action{
"updated_at": ActionIgnore, // Changes, but is implicit and not helpful in a diff.
"status": ActionTrack,
"rbac_roles": ActionTrack,
"login_type": ActionIgnore,
"login_type": ActionTrack,
"avatar_url": ActionIgnore,
"last_seen_at": ActionIgnore,
"deleted": ActionTrack,
@ -157,6 +157,13 @@ var auditableResourcesTypes = map[any]map[string]Action{
"scope": ActionIgnore,
"token_name": ActionIgnore,
},
&database.AuditOAuthConvertState{}: {
"created_at": ActionTrack,
"expires_at": ActionTrack,
"from_login_type": ActionTrack,
"to_login_type": ActionTrack,
"user_id": ActionTrack,
},
// TODO: track an ID here when the below ticket is completed:
// https://github.com/coder/coder/pull/6012
&database.License{}: {

View File

@ -108,6 +108,14 @@ export const login = async (
return response.data
}
export const convertToOAUTH = async (request: TypesGen.ConvertLoginRequest) => {
const response = await axios.post<TypesGen.OAuthConversionResponse>(
"/api/v2/users/me/convert-login",
request,
)
return response.data
}
export const logout = async (): Promise<void> => {
await axios.post("/api/v2/users/logout")
}
@ -134,6 +142,13 @@ export const getAuthMethods = async (): Promise<TypesGen.AuthMethods> => {
return response.data
}
export const getUserLoginType = async (): Promise<TypesGen.UserLoginType> => {
const response = await axios.get<TypesGen.UserLoginType>(
"/api/v2/users/me/login-type",
)
return response.data
}
export const checkAuthorization = async (
params: TypesGen.AuthorizationRequest,
): Promise<TypesGen.AuthorizationResponse> => {

View File

@ -104,6 +104,7 @@ export interface AuthMethod {
// From codersdk/users.go
export interface AuthMethods {
readonly convert_to_oidc_enabled: boolean
readonly password: AuthMethod
readonly github: AuthMethod
readonly oidc: OIDCAuthMethod
@ -139,6 +140,12 @@ export interface BuildInfoResponse {
readonly workspace_proxy: boolean
}
// From codersdk/users.go
export interface ConvertLoginRequest {
readonly to_type: LoginType
readonly password: string
}
// From codersdk/users.go
export interface CreateFirstUserRequest {
readonly email: string
@ -561,6 +568,14 @@ export interface OAuth2GithubConfig {
readonly enterprise_base_url: string
}
// From codersdk/users.go
export interface OAuthConversionResponse {
readonly state_string: string
readonly expires_at: string
readonly to_type: LoginType
readonly user_id: string
}
// From codersdk/users.go
export interface OIDCAuthMethod extends AuthMethod {
readonly signInText: string
@ -1082,6 +1097,11 @@ export interface User {
readonly avatar_url: string
}
// From codersdk/users.go
export interface UserLoginType {
readonly login_type: LoginType
}
// From codersdk/users.go
export interface UserRoles {
readonly roles: string[]
@ -1399,10 +1419,12 @@ export const Entitlements: Entitlement[] = [
// From codersdk/deployment.go
export type Experiment =
| "convert-to-oidc"
| "moons"
| "tailnet_pg_coordinator"
| "workspace_actions"
export const Experiments: Experiment[] = [
"convert-to-oidc",
"moons",
"tailnet_pg_coordinator",
"workspace_actions",
@ -1565,6 +1587,7 @@ export const RBACResources: RBACResource[] = [
// From codersdk/audit.go
export type ResourceType =
| "api_key"
| "convert_login"
| "git_ssh_key"
| "group"
| "license"
@ -1575,6 +1598,7 @@ export type ResourceType =
| "workspace_build"
export const ResourceTypes: ResourceType[] = [
"api_key",
"convert_login",
"git_ssh_key",
"group",
"license",

View File

@ -31,7 +31,7 @@ describe("AccountForm", () => {
const el = await screen.findByLabelText("Username")
expect(el).toBeEnabled()
const btn = await screen.findByRole("button", {
name: /Update settings/i,
name: /Update account/i,
})
expect(btn).toBeEnabled()
})
@ -61,7 +61,7 @@ describe("AccountForm", () => {
const el = await screen.findByLabelText("Username")
expect(el).toBeDisabled()
const btn = await screen.findByRole("button", {
name: /Update settings/i,
name: /Update account/i,
})
expect(btn).toBeDisabled()
})

View File

@ -8,8 +8,8 @@ import {
onChangeTrimmed,
} from "../../utils/formUtils"
import { LoadingButton } from "../LoadingButton/LoadingButton"
import { Stack } from "../Stack/Stack"
import { ErrorAlert } from "components/Alert/ErrorAlert"
import { Form, FormFields } from "components/Form/Form"
export interface AccountFormValues {
username: string
@ -18,7 +18,7 @@ export interface AccountFormValues {
export const Language = {
usernameLabel: "Username",
emailLabel: "Email",
updateSettings: "Update settings",
updateSettings: "Update account",
}
const validationSchema = Yup.object({
@ -59,8 +59,8 @@ export const AccountForm: FC<React.PropsWithChildren<AccountFormProps>> = ({
return (
<>
<form onSubmit={form.handleSubmit}>
<Stack>
<Form onSubmit={form.handleSubmit}>
<FormFields>
{Boolean(updateProfileError) && (
<ErrorAlert error={updateProfileError} />
)}
@ -91,8 +91,8 @@ export const AccountForm: FC<React.PropsWithChildren<AccountFormProps>> = ({
{isLoading ? "" : Language.updateSettings}
</LoadingButton>
</div>
</Stack>
</form>
</FormFields>
</Form>
</>
)
}

View File

@ -6,6 +6,8 @@ import { SectionAction } from "../SectionAction/SectionAction"
type SectionLayout = "fixed" | "fluid"
export interface SectionProps {
// Useful for testing
id?: string
title?: ReactNode | string
description?: ReactNode
toolbar?: ReactNode
@ -20,6 +22,7 @@ type SectionFC = FC<PropsWithChildren<SectionProps>> & {
}
export const Section: SectionFC = ({
id,
title,
description,
toolbar,
@ -30,12 +33,16 @@ export const Section: SectionFC = ({
}) => {
const styles = useStyles({ layout })
return (
<section className={className}>
<section className={className} id={id} data-testid={id}>
<div className={styles.inner}>
{(title || description) && (
<div className={styles.header}>
<div>
{title && <Typography variant="h4">{title}</Typography>}
{title && (
<Typography variant="h4" sx={{ fontSize: 24 }}>
{title}
</Typography>
)}
{description && typeof description === "string" && (
<Typography className={styles.description}>
{description}

View File

@ -17,12 +17,6 @@ const Template: Story<SecurityFormProps> = (args: SecurityFormProps) => (
export const Example = Template.bind({})
Example.args = {
isLoading: false,
initialValues: {
old_password: "",
password: "",
confirm_password: "",
},
updateSecurityError: undefined,
onSubmit: () => {
return Promise.resolve()
},
@ -37,7 +31,7 @@ Loading.args = {
export const WithError = Template.bind({})
WithError.args = {
...Example.args,
updateSecurityError: mockApiError({
error: mockApiError({
message: "Old password is incorrect",
validations: [
{
@ -46,7 +40,4 @@ WithError.args = {
},
],
}),
initialTouched: {
old_password: true,
},
}

View File

@ -1,11 +1,12 @@
import TextField from "@mui/material/TextField"
import { FormikContextType, FormikTouched, useFormik } from "formik"
import { FormikContextType, useFormik } from "formik"
import { FC } from "react"
import * as Yup from "yup"
import { getFormHelpers } from "../../utils/formUtils"
import { LoadingButton } from "../LoadingButton/LoadingButton"
import { Stack } from "../Stack/Stack"
import { ErrorAlert } from "components/Alert/ErrorAlert"
import { Form, FormFields } from "components/Form/Form"
import { Alert } from "components/Alert/Alert"
interface SecurityFormValues {
old_password: string
@ -41,40 +42,43 @@ const validationSchema = Yup.object({
})
export interface SecurityFormProps {
disabled: boolean
isLoading: boolean
initialValues: SecurityFormValues
onSubmit: (values: SecurityFormValues) => void
updateSecurityError?: Error | unknown
// initialTouched is only used for testing the error state of the form.
initialTouched?: FormikTouched<SecurityFormValues>
error?: unknown
}
export const SecurityForm: FC<SecurityFormProps> = ({
disabled,
isLoading,
onSubmit,
initialValues,
updateSecurityError,
initialTouched,
error,
}) => {
const form: FormikContextType<SecurityFormValues> =
useFormik<SecurityFormValues>({
initialValues,
initialValues: {
old_password: "",
password: "",
confirm_password: "",
},
validationSchema,
onSubmit,
initialTouched,
})
const getFieldHelpers = getFormHelpers<SecurityFormValues>(
form,
updateSecurityError,
)
const getFieldHelpers = getFormHelpers<SecurityFormValues>(form, error)
if (disabled) {
return (
<Alert severity="info">
Password changes are only allowed for password based accounts.
</Alert>
)
}
return (
<>
<form onSubmit={form.handleSubmit}>
<Stack>
{Boolean(updateSecurityError) && (
<ErrorAlert error={updateSecurityError} />
)}
<Form onSubmit={form.handleSubmit}>
<FormFields>
{Boolean(error) && <ErrorAlert error={error} />}
<TextField
{...getFieldHelpers("old_password")}
autoComplete="old_password"
@ -106,8 +110,8 @@ export const SecurityForm: FC<SecurityFormProps> = ({
{isLoading ? "" : Language.updatePassword}
</LoadingButton>
</div>
</Stack>
</form>
</FormFields>
</Form>
</>
)
}

View File

@ -28,6 +28,7 @@ SigningIn.args = {
...SignedOut.args,
isSigningIn: true,
authMethods: {
convert_to_oidc_enabled: false,
password: { enabled: true },
github: { enabled: true },
oidc: { enabled: false, signInText: "", iconUrl: "" },
@ -55,6 +56,7 @@ export const WithGithub = Template.bind({})
WithGithub.args = {
...SignedOut.args,
authMethods: {
convert_to_oidc_enabled: false,
password: { enabled: true },
github: { enabled: true },
oidc: { enabled: false, signInText: "", iconUrl: "" },
@ -65,6 +67,7 @@ export const WithOIDC = Template.bind({})
WithOIDC.args = {
...SignedOut.args,
authMethods: {
convert_to_oidc_enabled: false,
password: { enabled: true },
github: { enabled: false },
oidc: { enabled: true, signInText: "", iconUrl: "" },
@ -75,6 +78,7 @@ export const WithOIDCWithoutPassword = Template.bind({})
WithOIDCWithoutPassword.args = {
...SignedOut.args,
authMethods: {
convert_to_oidc_enabled: false,
password: { enabled: false },
github: { enabled: false },
oidc: { enabled: true, signInText: "", iconUrl: "" },
@ -85,6 +89,7 @@ export const WithoutAny = Template.bind({})
WithoutAny.args = {
...SignedOut.args,
authMethods: {
convert_to_oidc_enabled: false,
password: { enabled: false },
github: { enabled: false },
oidc: { enabled: false, signInText: "", iconUrl: "" },
@ -95,6 +100,7 @@ export const WithGithubAndOIDC = Template.bind({})
WithGithubAndOIDC.args = {
...SignedOut.args,
authMethods: {
convert_to_oidc_enabled: false,
password: { enabled: true },
github: { enabled: true },
oidc: { enabled: true, signInText: "", iconUrl: "" },

View File

@ -61,6 +61,7 @@ describe("LoginPage", () => {
it("shows github authentication when enabled", async () => {
const authMethods: TypesGen.AuthMethods = {
convert_to_oidc_enabled: false,
password: { enabled: true },
github: { enabled: true },
oidc: { enabled: true, signInText: "", iconUrl: "" },
@ -112,6 +113,7 @@ describe("LoginPage", () => {
it("hides password authentication if OIDC/GitHub is enabled and displays on click", async () => {
const authMethods: TypesGen.AuthMethods = {
convert_to_oidc_enabled: false,
password: { enabled: true },
github: { enabled: true },
oidc: { enabled: true, signInText: "", iconUrl: "" },

View File

@ -1,116 +1,159 @@
import { fireEvent, screen, waitFor } from "@testing-library/react"
import { fireEvent, screen, waitFor, within } from "@testing-library/react"
import * as API from "../../../api/api"
import * as SecurityForm from "../../../components/SettingsSecurityForm/SettingsSecurityForm"
import { renderWithAuth } from "../../../testHelpers/renderHelpers"
import {
renderWithAuth,
waitForLoaderToBeRemoved,
} from "../../../testHelpers/renderHelpers"
import { SecurityPage } from "./SecurityPage"
import i18next from "i18next"
import { mockApiError } from "testHelpers/entities"
import {
MockAuthMethodsWithPasswordType,
mockApiError,
} from "testHelpers/entities"
import userEvent from "@testing-library/user-event"
import * as SSO from "./SingleSignOnSection"
import { OAuthConversionResponse } from "api/typesGenerated"
const { t } = i18next
const renderPage = () => {
return renderWithAuth(<SecurityPage />)
const renderPage = async () => {
const utils = renderWithAuth(<SecurityPage />)
await waitForLoaderToBeRemoved()
return utils
}
const newData = {
const newSecurityFormValues = {
old_password: "password1",
password: "password2",
confirm_password: "password2",
}
const fillAndSubmitForm = async () => {
await waitFor(() => screen.findByLabelText("Old Password"))
const fillAndSubmitSecurityForm = () => {
fireEvent.change(screen.getByLabelText("Old Password"), {
target: { value: newData.old_password },
target: { value: newSecurityFormValues.old_password },
})
fireEvent.change(screen.getByLabelText("New Password"), {
target: { value: newData.password },
target: { value: newSecurityFormValues.password },
})
fireEvent.change(screen.getByLabelText("Confirm Password"), {
target: { value: newData.confirm_password },
target: { value: newSecurityFormValues.confirm_password },
})
fireEvent.click(screen.getByText(SecurityForm.Language.updatePassword))
}
describe("SecurityPage", () => {
describe("when it is a success", () => {
it("shows the success message", async () => {
jest
.spyOn(API, "updateUserPassword")
.mockImplementationOnce((_userId, _data) => Promise.resolve(undefined))
const { user } = renderPage()
await fillAndSubmitForm()
beforeEach(() => {
jest
.spyOn(API, "getAuthMethods")
.mockResolvedValue(MockAuthMethodsWithPasswordType)
jest.spyOn(API, "getUserLoginType").mockResolvedValue({
login_type: "password",
})
})
const expectedMessage = t("securityUpdateSuccessMessage", {
ns: "userSettingsPage",
})
const successMessage = await screen.findByText(expectedMessage)
expect(successMessage).toBeDefined()
expect(API.updateUserPassword).toBeCalledTimes(1)
expect(API.updateUserPassword).toBeCalledWith(user.id, newData)
test("update password successfully", async () => {
jest
.spyOn(API, "updateUserPassword")
.mockImplementationOnce((_userId, _data) => Promise.resolve(undefined))
const { user } = await renderPage()
fillAndSubmitSecurityForm()
await waitFor(() => expect(window.location).toBeAt("/"))
})
const expectedMessage = t("securityUpdateSuccessMessage", {
ns: "userSettingsPage",
})
const successMessage = await screen.findByText(expectedMessage)
expect(successMessage).toBeDefined()
expect(API.updateUserPassword).toBeCalledTimes(1)
expect(API.updateUserPassword).toBeCalledWith(user.id, newSecurityFormValues)
await waitFor(() => expect(window.location).toBeAt("/"))
})
test("update password with incorrect old password", async () => {
jest.spyOn(API, "updateUserPassword").mockRejectedValueOnce(
mockApiError({
message: "Incorrect password.",
validations: [{ detail: "Incorrect password.", field: "old_password" }],
}),
)
const { user } = await renderPage()
fillAndSubmitSecurityForm()
const errorMessage = await screen.findAllByText("Incorrect password.")
expect(errorMessage).toBeDefined()
expect(errorMessage).toHaveLength(2)
expect(API.updateUserPassword).toBeCalledTimes(1)
expect(API.updateUserPassword).toBeCalledWith(user.id, newSecurityFormValues)
})
test("update password with invalid password", async () => {
jest.spyOn(API, "updateUserPassword").mockRejectedValueOnce(
mockApiError({
message: "Invalid password.",
validations: [{ detail: "Invalid password.", field: "password" }],
}),
)
const { user } = await renderPage()
fillAndSubmitSecurityForm()
const errorMessage = await screen.findAllByText("Invalid password.")
expect(errorMessage).toBeDefined()
expect(errorMessage).toHaveLength(2)
expect(API.updateUserPassword).toBeCalledTimes(1)
expect(API.updateUserPassword).toBeCalledWith(user.id, newSecurityFormValues)
})
test("update password when submit returns an unknown error", async () => {
jest.spyOn(API, "updateUserPassword").mockRejectedValueOnce({
data: "unknown error",
})
describe("when the old_password is incorrect", () => {
it("shows an error", async () => {
jest.spyOn(API, "updateUserPassword").mockRejectedValueOnce(
mockApiError({
message: "Incorrect password.",
validations: [
{ detail: "Incorrect password.", field: "old_password" },
],
}),
)
const { user } = await renderPage()
fillAndSubmitSecurityForm()
const { user } = renderPage()
await fillAndSubmitForm()
const errorText = t("warningsAndErrors.somethingWentWrong", {
ns: "common",
})
const errorMessage = await screen.findByText(errorText)
expect(errorMessage).toBeDefined()
expect(API.updateUserPassword).toBeCalledTimes(1)
expect(API.updateUserPassword).toBeCalledWith(user.id, newSecurityFormValues)
})
const errorMessage = await screen.findAllByText("Incorrect password.")
expect(errorMessage).toBeDefined()
expect(errorMessage).toHaveLength(2)
expect(API.updateUserPassword).toBeCalledTimes(1)
expect(API.updateUserPassword).toBeCalledWith(user.id, newData)
})
test("change login type to OIDC", async () => {
const user = userEvent.setup()
const { user: userData } = await renderPage()
const convertToOAUTHSpy = jest
.spyOn(API, "convertToOAUTH")
.mockResolvedValue({
state_string: "some-state-string",
expires_at: "2021-01-01T00:00:00Z",
to_type: "oidc",
user_id: userData.id,
} as OAuthConversionResponse)
jest.spyOn(SSO, "redirectToOIDCAuth").mockImplementation(() => {
// Does a noop
})
describe("when the password is invalid", () => {
it("shows an error", async () => {
jest.spyOn(API, "updateUserPassword").mockRejectedValueOnce(
mockApiError({
message: "Invalid password.",
validations: [{ detail: "Invalid password.", field: "password" }],
}),
)
const ssoSection = screen.getByTestId("sso-section")
const githubButton = within(ssoSection).getByText("GitHub", { exact: false })
await user.click(githubButton)
const { user } = renderPage()
await fillAndSubmitForm()
const confirmationDialog = await screen.findByTestId("dialog")
const confirmPasswordField = within(confirmationDialog).getByLabelText(
"Confirm your password",
)
await user.type(confirmPasswordField, "password123")
const updateButton = within(confirmationDialog).getByText("Update")
await user.click(updateButton)
const errorMessage = await screen.findAllByText("Invalid password.")
expect(errorMessage).toBeDefined()
expect(errorMessage).toHaveLength(2)
expect(API.updateUserPassword).toBeCalledTimes(1)
expect(API.updateUserPassword).toBeCalledWith(user.id, newData)
})
})
describe("when it is an unknown error", () => {
it("shows a generic error message", async () => {
jest.spyOn(API, "updateUserPassword").mockRejectedValueOnce({
data: "unknown error",
})
const { user } = renderPage()
await fillAndSubmitForm()
const errorText = t("warningsAndErrors.somethingWentWrong", {
ns: "common",
})
const errorMessage = await screen.findByText(errorText)
expect(errorMessage).toBeDefined()
expect(API.updateUserPassword).toBeCalledTimes(1)
expect(API.updateUserPassword).toBeCalledWith(user.id, newData)
await waitFor(() => {
expect(convertToOAUTHSpy).toHaveBeenCalledWith({
password: "password123",
to_type: "github",
})
})
})

View File

@ -1,13 +1,17 @@
import { useMachine } from "@xstate/react"
import { useMe } from "hooks/useMe"
import { FC } from "react"
import { ComponentProps, FC } from "react"
import { userSecuritySettingsMachine } from "xServices/userSecuritySettings/userSecuritySettingsXService"
import { Section } from "../../../components/SettingsLayout/Section"
import { SecurityForm } from "../../../components/SettingsSecurityForm/SettingsSecurityForm"
export const Language = {
title: "Security",
}
import { useQuery } from "@tanstack/react-query"
import { getAuthMethods, getUserLoginType } from "api/api"
import {
SingleSignOnSection,
useSingleSignOnSection,
} from "./SingleSignOnSection"
import { Loader } from "components/Loader/Loader"
import { Stack } from "components/Stack/Stack"
export const SecurityPage: FC = () => {
const me = useMe()
@ -20,21 +24,68 @@ export const SecurityPage: FC = () => {
},
)
const { error } = securityState.context
const { data: authMethods } = useQuery({
queryKey: ["authMethods"],
queryFn: getAuthMethods,
})
const { data: userLoginType } = useQuery({
queryKey: ["loginType"],
queryFn: getUserLoginType,
})
const singleSignOnSection = useSingleSignOnSection()
if (!authMethods || !userLoginType) {
return <Loader />
}
return (
<Section title={Language.title} description="Update your account password">
<SecurityForm
updateSecurityError={error}
isLoading={securityState.matches("updatingSecurity")}
initialValues={{ old_password: "", password: "", confirm_password: "" }}
onSubmit={(data) => {
securitySend({
type: "UPDATE_SECURITY",
data,
})
}}
/>
</Section>
<SecurityPageView
security={{
form: {
disabled: userLoginType.login_type !== "password",
error,
isLoading: securityState.matches("updatingSecurity"),
onSubmit: (data) => {
securitySend({
type: "UPDATE_SECURITY",
data,
})
},
},
}}
oidc={
authMethods.convert_to_oidc_enabled
? {
section: {
authMethods,
userLoginType,
...singleSignOnSection,
},
}
: undefined
}
/>
)
}
export const SecurityPageView = ({
security,
oidc,
}: {
security: {
form: ComponentProps<typeof SecurityForm>
}
oidc?: {
section: ComponentProps<typeof SingleSignOnSection>
}
}) => {
return (
<Stack spacing={6}>
<Section title="Security" description="Update your account password">
<SecurityForm {...security.form} />
</Section>
{oidc && <SingleSignOnSection {...oidc.section} />}
</Stack>
)
}

View File

@ -0,0 +1,72 @@
import type { Meta, StoryObj } from "@storybook/react"
import { SecurityPageView } from "./SecurityPage"
import { action } from "@storybook/addon-actions"
import {
MockAuthMethods,
MockAuthMethodsWithPasswordType,
} from "testHelpers/entities"
import { ComponentProps } from "react"
import set from "lodash/fp/set"
const defaultArgs: ComponentProps<typeof SecurityPageView> = {
security: {
form: {
disabled: false,
error: undefined,
isLoading: false,
onSubmit: action("onSubmit"),
},
},
oidc: {
section: {
userLoginType: {
login_type: "password",
},
authMethods: MockAuthMethods,
closeConfirmation: action("closeConfirmation"),
confirm: action("confirm"),
error: undefined,
isConfirming: false,
isUpdating: false,
openConfirmation: action("openConfirmation"),
},
},
}
const meta: Meta<typeof SecurityPageView> = {
title: "pages/SecurityPageView",
component: SecurityPageView,
args: defaultArgs,
}
export default meta
type Story = StoryObj<typeof SecurityPageView>
export const UsingOIDC: Story = {}
export const NoOIDCAvailable: Story = {
args: {
...defaultArgs,
oidc: undefined,
},
}
export const UserLoginTypeIsPassword: Story = {
args: set(
"oidc.section.authMethods",
MockAuthMethodsWithPasswordType,
defaultArgs,
),
}
export const ConfirmingOIDCConversion: Story = {
args: set(
"oidc.section",
{
...defaultArgs.oidc?.section,
authMethods: MockAuthMethodsWithPasswordType,
isConfirming: true,
},
defaultArgs,
),
}

View File

@ -0,0 +1,260 @@
import { useState } from "react"
import { Section } from "../../../components/SettingsLayout/Section"
import TextField from "@mui/material/TextField"
import Box from "@mui/material/Box"
import GitHubIcon from "@mui/icons-material/GitHub"
import KeyIcon from "@mui/icons-material/VpnKey"
import Button from "@mui/material/Button"
import { useLocation } from "react-router-dom"
import { retrieveRedirect } from "utils/redirect"
import Typography from "@mui/material/Typography"
import { convertToOAUTH } from "api/api"
import { AuthMethods, LoginType, UserLoginType } from "api/typesGenerated"
import Skeleton from "@mui/material/Skeleton"
import { Stack } from "components/Stack/Stack"
import { useMutation } from "@tanstack/react-query"
import { ConfirmDialog } from "components/Dialogs/ConfirmDialog/ConfirmDialog"
import { getErrorMessage } from "api/errors"
import CheckCircleOutlined from "@mui/icons-material/CheckCircleOutlined"
type LoginTypeConfirmation =
| {
open: false
selectedType: undefined
}
| {
open: true
selectedType: LoginType
}
export const redirectToOIDCAuth = (stateString: string, redirectTo: string) => {
window.location.href = `/api/v2/users/oidc/callback?oidc_merge_state=${stateString}&redirect=${redirectTo}`
}
export const useSingleSignOnSection = () => {
const location = useLocation()
const redirectTo = retrieveRedirect(location.search)
const [loginTypeConfirmation, setLoginTypeConfirmation] =
useState<LoginTypeConfirmation>({ open: false, selectedType: undefined })
const mutation = useMutation(convertToOAUTH, {
onSuccess: (data) => {
redirectToOIDCAuth(data.state_string, encodeURIComponent(redirectTo))
},
})
const openConfirmation = (selectedType: LoginType) => {
setLoginTypeConfirmation({ open: true, selectedType })
}
const closeConfirmation = () => {
setLoginTypeConfirmation({ open: false, selectedType: undefined })
mutation.reset()
}
const confirm = (password: string) => {
if (!loginTypeConfirmation.selectedType) {
throw new Error("No login type selected")
}
mutation.mutate({
to_type: loginTypeConfirmation.selectedType,
password,
})
}
return {
openConfirmation,
closeConfirmation,
confirm,
// We still want to show it loading when it is success so the modal does not
// change until the redirect
isUpdating: mutation.isLoading || mutation.isSuccess,
isConfirming: loginTypeConfirmation.open,
error: mutation.error,
}
}
type SingleSignOnSectionProps = ReturnType<typeof useSingleSignOnSection> & {
authMethods: AuthMethods
userLoginType: UserLoginType
}
export const SingleSignOnSection = ({
authMethods,
userLoginType,
openConfirmation,
closeConfirmation,
confirm,
isUpdating,
isConfirming,
error,
}: SingleSignOnSectionProps) => {
return (
<>
<Section
id="sso-section"
title="Single Sign On"
description="Authenticate in Coder using one-click"
>
<Box display="grid" gap="16px">
{authMethods && userLoginType ? (
userLoginType.login_type === "password" ? (
<>
{authMethods.github.enabled && (
<Button
disabled={isUpdating}
onClick={() => openConfirmation("github")}
startIcon={<GitHubIcon sx={{ width: 16, height: 16 }} />}
fullWidth
size="large"
>
GitHub
</Button>
)}
{authMethods.oidc.enabled && (
<Button
size="large"
startIcon={<OIDCIcon authMethods={authMethods} />}
fullWidth
disabled={isUpdating}
onClick={() => openConfirmation("oidc")}
>
{getOIDCLabel(authMethods)}
</Button>
)}
</>
) : (
<Box
sx={{
background: (theme) => theme.palette.background.paper,
borderRadius: 1,
border: (theme) => `1px solid ${theme.palette.divider}`,
padding: 2,
display: "flex",
gap: 2,
alignItems: "center",
fontSize: 14,
}}
>
<CheckCircleOutlined
sx={{
color: (theme) => theme.palette.success.light,
fontSize: 16,
}}
/>
<span>
Authenticated with{" "}
<strong>
{userLoginType.login_type === "github"
? "GitHub"
: getOIDCLabel(authMethods)}
</strong>
</span>
<Box sx={{ ml: "auto", lineHeight: 1 }}>
{userLoginType.login_type === "github" ? (
<GitHubIcon sx={{ width: 16, height: 16 }} />
) : (
<OIDCIcon authMethods={authMethods} />
)}
</Box>
</Box>
)
) : (
<Skeleton
variant="rectangular"
sx={{ height: 40, borderRadius: 1 }}
/>
)}
</Box>
</Section>
<ConfirmLoginTypeChangeModal
open={isConfirming}
error={error}
loading={isUpdating}
onClose={closeConfirmation}
onConfirm={confirm}
/>
</>
)
}
const OIDCIcon = ({ authMethods }: { authMethods: AuthMethods }) => {
return authMethods.oidc.iconUrl ? (
<Box
component="img"
alt="Open ID Connect icon"
src={authMethods.oidc.iconUrl}
sx={{ width: 16, height: 16 }}
/>
) : (
<KeyIcon sx={{ width: 16, height: 16 }} />
)
}
const getOIDCLabel = (authMethods: AuthMethods) => {
return authMethods.oidc.signInText || "OpenID Connect"
}
const ConfirmLoginTypeChangeModal = ({
open,
loading,
error,
onClose,
onConfirm,
}: {
open: boolean
loading: boolean
error: unknown
onClose: () => void
onConfirm: (password: string) => void
}) => {
const [password, setPassword] = useState("")
const handleConfirm = () => {
onConfirm(password)
}
return (
<ConfirmDialog
open={open}
onClose={() => {
onClose()
}}
onConfirm={handleConfirm}
hideCancel={false}
cancelText="Cancel"
confirmText="Update"
title="Change login type"
confirmLoading={loading}
description={
<Stack>
<Typography>
After changing your login type, you will not be able to change it
again. Are you sure you want to proceed and change your login type?
</Typography>
<TextField
autoFocus
onKeyDown={(event) => {
if (event.key === "Enter") {
handleConfirm()
}
}}
error={Boolean(error)}
helperText={
error
? getErrorMessage(error, "Your password is incorrect")
: undefined
}
name="confirm-password"
id="confirm-password"
value={password}
onChange={(e) => setPassword(e.currentTarget.value)}
label="Confirm your password"
type="password"
/>
</Stack>
}
/>
)
}

View File

@ -1023,6 +1023,13 @@ export const MockAuthMethods: TypesGen.AuthMethods = {
password: { enabled: true },
github: { enabled: false },
oidc: { enabled: false, signInText: "", iconUrl: "" },
convert_to_oidc_enabled: true,
}
export const MockAuthMethodsWithPasswordType: TypesGen.AuthMethods = {
...MockAuthMethods,
github: { enabled: true },
oidc: { enabled: true, signInText: "", iconUrl: "" },
}
export const MockGitSSHKey: TypesGen.GitSSHKey = {
@ -1507,6 +1514,42 @@ export const MockAuditLogGitSSH: TypesGen.AuditLog = {
},
}
export const MockAuditOauthConvert: TypesGen.AuditLog = {
...MockAuditLog,
resource_type: "convert_login",
resource_target: "oidc",
action: "create",
status_code: 201,
description: "{user} created login type conversion to {target}}",
diff: {
created_at: {
old: "0001-01-01T00:00:00Z",
new: "2023-06-20T20:44:54.243019Z",
secret: false,
},
expires_at: {
old: "0001-01-01T00:00:00Z",
new: "2023-06-20T20:49:54.243019Z",
secret: false,
},
state_string: {
old: "",
new: "",
secret: true,
},
to_type: {
old: "",
new: "oidc",
secret: false,
},
user_id: {
old: "",
new: "dc790496-eaec-4f88-a53f-8ce1f61a1fff",
secret: false,
},
},
}
export const MockAuditLogSuccessfulLogin: TypesGen.AuditLog = {
...MockAuditLog,
resource_type: "api_key",

View File

@ -134,6 +134,14 @@ export const handlers = [
rest.post("/api/v2/users", async (req, res, ctx) => {
return res(ctx.status(200), ctx.json(M.MockUser))
}),
rest.get("/api/v2/users/:userid/login-type", async (req, res, ctx) => {
return res(
ctx.status(200),
ctx.json({
login_type: "password",
}),
)
}),
rest.get("/api/v2/users/me/organizations", (req, res, ctx) => {
return res(ctx.status(200), ctx.json([M.MockOrganization]))
}),