mirror of
https://github.com/coder/coder.git
synced 2026-09-22 05:05:20 +08:00
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:
co-authored by
Bruno Quaresma
parent
357f3b38f7
commit
b5f26d9bdf
Generated
+138
-4
@@ -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": [
|
||||
|
||||
Generated
+129
-4
@@ -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"],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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{}),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
Generated
+2
-1
@@ -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';
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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))
|
||||
})
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user