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
co-authored by Bruno Quaresma
parent 357f3b38f7
commit b5f26d9bdf
50 changed files with 2043 additions and 261 deletions
+138 -4
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": [
+129 -4
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"],
+4
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
+2 -1
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
+7
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))
}
+20 -2
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
+21
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
+37 -1
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()
+1 -1
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{}),
+21
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)
+44
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()
+2 -1
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 (
@@ -0,0 +1 @@
-- Nothing to do
@@ -0,0 +1,2 @@
-- This has to be outside a transaction
ALTER TYPE resource_type ADD VALUE IF NOT EXISTS 'convert_login';
+4 -1
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,
}
}
+3
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)
+47
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)
+65 -3
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
+7 -1
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';
+18 -3
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
+13
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 {
+24 -11
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))
})
+18
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")
})
}
+432 -75
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,
}
}
+47 -3
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)
+31
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