2022-04-23 22:58:57 +00:00
|
|
|
package coderd
|
|
|
|
|
|
|
|
import (
|
|
|
|
"context"
|
|
|
|
"database/sql"
|
|
|
|
"errors"
|
|
|
|
"fmt"
|
|
|
|
"net/http"
|
2022-10-17 19:14:49 +00:00
|
|
|
"net/mail"
|
2022-08-17 23:00:53 +00:00
|
|
|
"strconv"
|
2022-08-01 04:05:35 +00:00
|
|
|
"strings"
|
2022-04-23 22:58:57 +00:00
|
|
|
|
2022-08-01 04:05:35 +00:00
|
|
|
"github.com/coreos/go-oidc/v3/oidc"
|
2022-04-23 22:58:57 +00:00
|
|
|
"github.com/google/go-github/v43/github"
|
|
|
|
"github.com/google/uuid"
|
2022-10-18 03:07:11 +00:00
|
|
|
"github.com/moby/moby/pkg/namesgenerator"
|
2022-04-23 22:58:57 +00:00
|
|
|
"golang.org/x/oauth2"
|
2022-08-17 23:00:53 +00:00
|
|
|
"golang.org/x/xerrors"
|
2022-04-23 22:58:57 +00:00
|
|
|
|
2023-02-06 20:12:50 +00:00
|
|
|
"cdr.dev/slog"
|
|
|
|
"github.com/coder/coder/coderd/audit"
|
2022-04-23 22:58:57 +00:00
|
|
|
"github.com/coder/coder/coderd/database"
|
2023-02-14 14:27:06 +00:00
|
|
|
"github.com/coder/coder/coderd/database/dbauthz"
|
2022-04-23 22:58:57 +00:00
|
|
|
"github.com/coder/coder/coderd/httpapi"
|
|
|
|
"github.com/coder/coder/coderd/httpmw"
|
2023-02-14 15:57:34 +00:00
|
|
|
"github.com/coder/coder/coderd/rbac"
|
2023-02-06 20:12:50 +00:00
|
|
|
"github.com/coder/coder/coderd/userpassword"
|
2022-04-23 22:58:57 +00:00
|
|
|
"github.com/coder/coder/codersdk"
|
|
|
|
)
|
|
|
|
|
2023-02-06 20:12:50 +00:00
|
|
|
// Authenticates the user with an email and password.
|
|
|
|
//
|
|
|
|
// @Summary Log in user
|
|
|
|
// @ID log-in-user
|
|
|
|
// @Accept json
|
|
|
|
// @Produce json
|
|
|
|
// @Tags Authorization
|
|
|
|
// @Param request body codersdk.LoginWithPasswordRequest true "Login request"
|
|
|
|
// @Success 201 {object} codersdk.LoginWithPasswordResponse
|
|
|
|
// @Router /users/login [post]
|
|
|
|
func (api *API) postLogin(rw http.ResponseWriter, r *http.Request) {
|
|
|
|
var (
|
|
|
|
ctx = r.Context()
|
|
|
|
auditor = api.Auditor.Load()
|
|
|
|
aReq, commitAudit = audit.InitRequest[database.APIKey](rw, &audit.RequestParams{
|
|
|
|
Audit: *auditor,
|
|
|
|
Log: api.Logger,
|
|
|
|
Request: r,
|
|
|
|
Action: database.AuditActionLogin,
|
|
|
|
})
|
|
|
|
)
|
|
|
|
aReq.Old = database.APIKey{}
|
|
|
|
defer commitAudit()
|
|
|
|
|
|
|
|
var loginWithPassword codersdk.LoginWithPasswordRequest
|
|
|
|
if !httpapi.Read(ctx, rw, r, &loginWithPassword) {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
2023-02-14 14:27:06 +00:00
|
|
|
//nolint:gocritic // In order to login, we need to get the user first!
|
2023-02-15 16:14:37 +00:00
|
|
|
user, err := api.Database.GetUserByEmailOrUsername(dbauthz.AsSystemRestricted(ctx), database.GetUserByEmailOrUsernameParams{
|
2023-02-06 20:12:50 +00:00
|
|
|
Email: loginWithPassword.Email,
|
|
|
|
})
|
|
|
|
if err != nil && !xerrors.Is(err, sql.ErrNoRows) {
|
|
|
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
|
|
|
Message: "Internal error.",
|
|
|
|
})
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
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 {
|
|
|
|
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.
|
2023-03-07 21:10:01 +00:00
|
|
|
if api.DeploymentValues.DisablePasswordAuth {
|
2023-02-14 14:58:12 +00:00
|
|
|
httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{
|
|
|
|
Message: "Password authentication is disabled.",
|
|
|
|
})
|
|
|
|
return
|
2023-02-06 20:12:50 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
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
|
|
|
|
}
|
|
|
|
|
2023-02-14 14:27:06 +00:00
|
|
|
//nolint:gocritic // System needs to fetch user roles in order to login user.
|
2023-02-15 16:14:37 +00:00
|
|
|
roles, err := api.Database.GetAuthorizationUserRoles(dbauthz.AsSystemRestricted(ctx), user.ID)
|
2023-02-14 14:27:06 +00:00
|
|
|
if err != nil {
|
|
|
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
|
|
|
Message: "Internal error.",
|
|
|
|
})
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
2023-02-06 20:12:50 +00:00
|
|
|
// If the user logged into a suspended account, reject the login request.
|
2023-02-14 14:27:06 +00:00
|
|
|
if roles.Status != database.UserStatusActive {
|
2023-02-06 20:12:50 +00:00
|
|
|
httpapi.Write(ctx, rw, http.StatusUnauthorized, codersdk.Response{
|
|
|
|
Message: "Your account is suspended. Contact an admin to reactivate your account.",
|
|
|
|
})
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
2023-02-14 14:27:06 +00:00
|
|
|
userSubj := rbac.Subject{
|
|
|
|
ID: user.ID.String(),
|
|
|
|
Roles: rbac.RoleNames(roles.Roles),
|
|
|
|
Groups: roles.Groups,
|
|
|
|
Scope: rbac.ScopeAll,
|
|
|
|
}
|
|
|
|
|
|
|
|
//nolint:gocritic // Creating the API key as the user instead of as system.
|
|
|
|
cookie, key, err := api.createAPIKey(dbauthz.As(ctx, userSubj), createAPIKeyParams{
|
2023-02-06 20:12:50 +00:00
|
|
|
UserID: user.ID,
|
|
|
|
LoginType: database.LoginTypePassword,
|
|
|
|
RemoteAddr: r.RemoteAddr,
|
|
|
|
})
|
|
|
|
if err != nil {
|
|
|
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
|
|
|
Message: "Failed to create API key.",
|
|
|
|
Detail: err.Error(),
|
|
|
|
})
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
aReq.New = *key
|
|
|
|
|
|
|
|
http.SetCookie(rw, cookie)
|
|
|
|
|
|
|
|
httpapi.Write(ctx, rw, http.StatusCreated, codersdk.LoginWithPasswordResponse{
|
|
|
|
SessionToken: cookie.Value,
|
|
|
|
})
|
|
|
|
}
|
|
|
|
|
|
|
|
// Clear the user's session cookie.
|
|
|
|
//
|
|
|
|
// @Summary Log out user
|
|
|
|
// @ID log-out-user
|
|
|
|
// @Security CoderSessionToken
|
|
|
|
// @Produce json
|
|
|
|
// @Tags Users
|
|
|
|
// @Success 200 {object} codersdk.Response
|
|
|
|
// @Router /users/logout [post]
|
|
|
|
func (api *API) postLogout(rw http.ResponseWriter, r *http.Request) {
|
|
|
|
var (
|
|
|
|
ctx = r.Context()
|
|
|
|
auditor = api.Auditor.Load()
|
|
|
|
aReq, commitAudit = audit.InitRequest[database.APIKey](rw, &audit.RequestParams{
|
|
|
|
Audit: *auditor,
|
|
|
|
Log: api.Logger,
|
|
|
|
Request: r,
|
|
|
|
Action: database.AuditActionLogout,
|
|
|
|
})
|
|
|
|
)
|
|
|
|
defer commitAudit()
|
|
|
|
|
|
|
|
// Get a blank token cookie.
|
|
|
|
cookie := &http.Cookie{
|
|
|
|
// MaxAge < 0 means to delete the cookie now.
|
|
|
|
MaxAge: -1,
|
|
|
|
Name: codersdk.SessionTokenCookie,
|
|
|
|
Path: "/",
|
|
|
|
}
|
|
|
|
http.SetCookie(rw, cookie)
|
|
|
|
|
|
|
|
// Delete the session token from database.
|
|
|
|
apiKey := httpmw.APIKey(r)
|
|
|
|
aReq.Old = apiKey
|
|
|
|
|
|
|
|
err := api.Database.DeleteAPIKeyByID(ctx, apiKey.ID)
|
|
|
|
if err != nil {
|
|
|
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
|
|
|
Message: "Internal error deleting API key.",
|
|
|
|
Detail: err.Error(),
|
|
|
|
})
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
// Deployments should not host app tokens on the same domain as the
|
|
|
|
// primary deployment. But in the case they are, we should also delete this
|
|
|
|
// token.
|
2023-03-07 19:38:11 +00:00
|
|
|
if appCookie, _ := r.Cookie(codersdk.DevURLSessionTokenCookie); appCookie != nil {
|
2023-02-06 20:12:50 +00:00
|
|
|
appCookieRemove := &http.Cookie{
|
|
|
|
// MaxAge < 0 means to delete the cookie now.
|
|
|
|
MaxAge: -1,
|
2023-03-07 19:38:11 +00:00
|
|
|
Name: codersdk.DevURLSessionTokenCookie,
|
2023-02-06 20:12:50 +00:00
|
|
|
Path: "/",
|
|
|
|
Domain: "." + api.AccessURL.Hostname(),
|
|
|
|
}
|
|
|
|
http.SetCookie(rw, appCookieRemove)
|
|
|
|
|
|
|
|
id, _, err := httpmw.SplitAPIToken(appCookie.Value)
|
|
|
|
if err == nil {
|
|
|
|
err = api.Database.DeleteAPIKeyByID(ctx, id)
|
|
|
|
if err != nil {
|
|
|
|
// Don't block logout, just log any errors.
|
|
|
|
api.Logger.Warn(r.Context(), "failed to delete devurl token on logout",
|
|
|
|
slog.Error(err),
|
|
|
|
slog.F("id", id),
|
|
|
|
)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// This code should be removed after Jan 1 2023.
|
|
|
|
// This code logs out of the old session cookie before we renamed it
|
|
|
|
// if it is a valid coder token. Otherwise, this old cookie hangs around
|
|
|
|
// and we never log out of the user.
|
|
|
|
oldCookie, err := r.Cookie("session_token")
|
|
|
|
if err == nil && oldCookie != nil {
|
|
|
|
_, _, err := httpmw.SplitAPIToken(oldCookie.Value)
|
|
|
|
if err == nil {
|
|
|
|
cookie := &http.Cookie{
|
|
|
|
// MaxAge < 0 means to delete the cookie now.
|
|
|
|
MaxAge: -1,
|
|
|
|
Name: "session_token",
|
|
|
|
Path: "/",
|
|
|
|
}
|
|
|
|
http.SetCookie(rw, cookie)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
aReq.New = database.APIKey{}
|
|
|
|
|
|
|
|
httpapi.Write(ctx, rw, http.StatusOK, codersdk.Response{
|
|
|
|
Message: "Logged out!",
|
|
|
|
})
|
|
|
|
}
|
|
|
|
|
2022-07-09 02:37:18 +00:00
|
|
|
// GithubOAuth2Team represents a team scoped to an organization.
|
|
|
|
type GithubOAuth2Team struct {
|
|
|
|
Organization string
|
|
|
|
Slug string
|
|
|
|
}
|
|
|
|
|
2022-04-23 22:58:57 +00:00
|
|
|
// GithubOAuth2Provider exposes required functions for the Github authentication flow.
|
|
|
|
type GithubOAuth2Config struct {
|
|
|
|
httpmw.OAuth2Config
|
|
|
|
AuthenticatedUser func(ctx context.Context, client *http.Client) (*github.User, error)
|
|
|
|
ListEmails func(ctx context.Context, client *http.Client) ([]*github.UserEmail, error)
|
|
|
|
ListOrganizationMemberships func(ctx context.Context, client *http.Client) ([]*github.Membership, error)
|
2022-07-22 18:54:08 +00:00
|
|
|
TeamMembership func(ctx context.Context, client *http.Client, org, team, username string) (*github.Membership, error)
|
2022-04-23 22:58:57 +00:00
|
|
|
|
|
|
|
AllowSignups bool
|
2022-11-15 16:56:46 +00:00
|
|
|
AllowEveryone bool
|
2022-04-23 22:58:57 +00:00
|
|
|
AllowOrganizations []string
|
2022-07-09 02:37:18 +00:00
|
|
|
AllowTeams []GithubOAuth2Team
|
2022-04-23 22:58:57 +00:00
|
|
|
}
|
|
|
|
|
2023-01-11 13:08:04 +00:00
|
|
|
// @Summary Get authentication methods
|
|
|
|
// @ID get-authentication-methods
|
|
|
|
// @Security CoderSessionToken
|
|
|
|
// @Produce json
|
|
|
|
// @Tags Users
|
|
|
|
// @Success 200 {object} codersdk.AuthMethods
|
|
|
|
// @Router /users/authmethods [get]
|
2022-09-21 22:07:00 +00:00
|
|
|
func (api *API) userAuthMethods(rw http.ResponseWriter, r *http.Request) {
|
2023-01-31 18:33:25 +00:00
|
|
|
var signInText string
|
|
|
|
var iconURL string
|
|
|
|
|
|
|
|
if api.OIDCConfig != nil {
|
|
|
|
signInText = api.OIDCConfig.SignInText
|
|
|
|
}
|
|
|
|
if api.OIDCConfig != nil {
|
|
|
|
iconURL = api.OIDCConfig.IconURL
|
|
|
|
}
|
|
|
|
|
2022-09-21 22:07:00 +00:00
|
|
|
httpapi.Write(r.Context(), rw, http.StatusOK, codersdk.AuthMethods{
|
2023-02-06 14:58:21 +00:00
|
|
|
Password: codersdk.AuthMethod{
|
2023-03-07 21:10:01 +00:00
|
|
|
Enabled: !api.DeploymentValues.DisablePasswordAuth.Value(),
|
2023-02-06 14:58:21 +00:00
|
|
|
},
|
|
|
|
Github: codersdk.AuthMethod{Enabled: api.GithubOAuth2Config != nil},
|
2023-01-31 18:33:25 +00:00
|
|
|
OIDC: codersdk.OIDCAuthMethod{
|
|
|
|
AuthMethod: codersdk.AuthMethod{Enabled: api.OIDCConfig != nil},
|
|
|
|
SignInText: signInText,
|
|
|
|
IconURL: iconURL,
|
|
|
|
},
|
2022-04-23 22:58:57 +00:00
|
|
|
})
|
|
|
|
}
|
|
|
|
|
2023-01-11 13:08:04 +00:00
|
|
|
// @Summary OAuth 2.0 GitHub Callback
|
2023-01-13 11:27:21 +00:00
|
|
|
// @ID oauth-20-github-callback
|
2023-01-11 13:08:04 +00:00
|
|
|
// @Security CoderSessionToken
|
|
|
|
// @Tags Users
|
|
|
|
// @Success 307
|
|
|
|
// @Router /users/oauth2/github/callback [get]
|
2022-05-26 03:14:08 +00:00
|
|
|
func (api *API) userOAuth2Github(rw http.ResponseWriter, r *http.Request) {
|
2022-08-17 23:00:53 +00:00
|
|
|
var (
|
2023-03-04 20:32:07 +00:00
|
|
|
// userOAuth2Github is a system function.
|
|
|
|
//nolint:gocritic
|
|
|
|
ctx = dbauthz.AsSystemRestricted(r.Context())
|
2023-02-06 20:12:50 +00:00
|
|
|
state = httpmw.OAuth2(r)
|
|
|
|
auditor = api.Auditor.Load()
|
|
|
|
aReq, commitAudit = audit.InitRequest[database.APIKey](rw, &audit.RequestParams{
|
|
|
|
Audit: *auditor,
|
|
|
|
Log: api.Logger,
|
|
|
|
Request: r,
|
|
|
|
Action: database.AuditActionLogin,
|
|
|
|
})
|
2022-08-17 23:00:53 +00:00
|
|
|
)
|
2023-02-06 20:12:50 +00:00
|
|
|
aReq.Old = database.APIKey{}
|
|
|
|
defer commitAudit()
|
2022-04-23 22:58:57 +00:00
|
|
|
|
2022-08-17 23:00:53 +00:00
|
|
|
oauthClient := oauth2.NewClient(ctx, oauth2.StaticTokenSource(state.Token))
|
2022-11-15 16:56:46 +00:00
|
|
|
|
|
|
|
var selectedMemberships []*github.Membership
|
|
|
|
var organizationNames []string
|
|
|
|
if !api.GithubOAuth2Config.AllowEveryone {
|
|
|
|
memberships, err := api.GithubOAuth2Config.ListOrganizationMemberships(ctx, oauthClient)
|
|
|
|
if err != nil {
|
|
|
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
|
|
|
Message: "Internal error fetching authenticated Github user organizations.",
|
|
|
|
Detail: err.Error(),
|
|
|
|
})
|
|
|
|
return
|
2022-10-07 16:53:58 +00:00
|
|
|
}
|
2022-11-15 16:56:46 +00:00
|
|
|
|
|
|
|
for _, membership := range memberships {
|
|
|
|
if membership.GetState() != "active" {
|
2022-04-23 22:58:57 +00:00
|
|
|
continue
|
|
|
|
}
|
2022-11-15 16:56:46 +00:00
|
|
|
for _, allowed := range api.GithubOAuth2Config.AllowOrganizations {
|
|
|
|
if *membership.Organization.Login != allowed {
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
selectedMemberships = append(selectedMemberships, membership)
|
|
|
|
organizationNames = append(organizationNames, membership.Organization.GetLogin())
|
|
|
|
break
|
|
|
|
}
|
|
|
|
}
|
|
|
|
if len(selectedMemberships) == 0 {
|
|
|
|
httpapi.Write(ctx, rw, http.StatusUnauthorized, codersdk.Response{
|
|
|
|
Message: "You aren't a member of the authorized Github organizations!",
|
|
|
|
})
|
|
|
|
return
|
2022-04-23 22:58:57 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2022-08-17 23:00:53 +00:00
|
|
|
ghUser, err := api.GithubOAuth2Config.AuthenticatedUser(ctx, oauthClient)
|
2022-07-22 18:54:08 +00:00
|
|
|
if err != nil {
|
2022-09-21 22:07:00 +00:00
|
|
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
2022-07-22 18:54:08 +00:00
|
|
|
Message: "Internal error fetching authenticated Github user.",
|
|
|
|
Detail: err.Error(),
|
|
|
|
})
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
2022-07-09 02:37:18 +00:00
|
|
|
// The default if no teams are specified is to allow all.
|
2022-11-15 16:56:46 +00:00
|
|
|
if !api.GithubOAuth2Config.AllowEveryone && len(api.GithubOAuth2Config.AllowTeams) > 0 {
|
2022-07-22 18:54:08 +00:00
|
|
|
var allowedTeam *github.Membership
|
2022-07-13 00:45:43 +00:00
|
|
|
for _, allowTeam := range api.GithubOAuth2Config.AllowTeams {
|
2022-11-15 16:56:46 +00:00
|
|
|
if allowedTeam != nil {
|
|
|
|
break
|
2022-07-09 02:37:18 +00:00
|
|
|
}
|
2022-11-15 16:56:46 +00:00
|
|
|
for _, selectedMembership := range selectedMemberships {
|
|
|
|
if allowTeam.Organization != *selectedMembership.Organization.Login {
|
|
|
|
// This needs to continue because multiple organizations
|
|
|
|
// could exist in the allow/team listings.
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
|
|
|
|
allowedTeam, err = api.GithubOAuth2Config.TeamMembership(ctx, oauthClient, allowTeam.Organization, allowTeam.Slug, *ghUser.Login)
|
|
|
|
// The calling user may not have permission to the requested team!
|
|
|
|
if err != nil {
|
|
|
|
continue
|
|
|
|
}
|
2022-07-13 00:45:43 +00:00
|
|
|
}
|
|
|
|
}
|
2022-07-09 02:37:18 +00:00
|
|
|
if allowedTeam == nil {
|
2022-09-21 22:07:00 +00:00
|
|
|
httpapi.Write(ctx, rw, http.StatusUnauthorized, codersdk.Response{
|
2022-11-15 16:56:46 +00:00
|
|
|
Message: fmt.Sprintf("You aren't a member of an authorized team in the %v Github organization(s)!", organizationNames),
|
2022-07-09 02:37:18 +00:00
|
|
|
})
|
|
|
|
return
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2022-08-17 23:00:53 +00:00
|
|
|
emails, err := api.GithubOAuth2Config.ListEmails(ctx, oauthClient)
|
2022-04-23 22:58:57 +00:00
|
|
|
if err != nil {
|
2022-09-21 22:07:00 +00:00
|
|
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
2022-06-07 14:33:06 +00:00
|
|
|
Message: "Internal error fetching personal Github user.",
|
2022-06-03 21:48:09 +00:00
|
|
|
Detail: err.Error(),
|
2022-04-23 22:58:57 +00:00
|
|
|
})
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
2022-08-22 23:13:46 +00:00
|
|
|
var verifiedEmail *github.UserEmail
|
2022-04-23 22:58:57 +00:00
|
|
|
for _, email := range emails {
|
2022-08-22 23:13:46 +00:00
|
|
|
if email.GetVerified() && email.GetPrimary() {
|
|
|
|
verifiedEmail = email
|
|
|
|
break
|
2022-04-23 22:58:57 +00:00
|
|
|
}
|
2022-08-17 23:00:53 +00:00
|
|
|
}
|
|
|
|
|
2022-08-22 23:13:46 +00:00
|
|
|
if verifiedEmail == nil {
|
2023-01-13 14:30:48 +00:00
|
|
|
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
2022-08-22 23:13:46 +00:00
|
|
|
Message: "Your primary email must be verified on GitHub!",
|
2022-04-23 22:58:57 +00:00
|
|
|
})
|
2022-08-17 23:00:53 +00:00
|
|
|
return
|
|
|
|
}
|
|
|
|
|
2023-02-06 20:12:50 +00:00
|
|
|
user, link, err := findLinkedUser(ctx, api.Database, githubLinkedID(ghUser), verifiedEmail.GetEmail())
|
|
|
|
if err != nil {
|
|
|
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
|
|
|
Message: "Failed to find linked user.",
|
|
|
|
Detail: err.Error(),
|
|
|
|
})
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
cookie, key, err := api.oauthLogin(r, oauthLoginParams{
|
|
|
|
User: user,
|
|
|
|
Link: link,
|
2022-08-22 23:13:46 +00:00
|
|
|
State: state,
|
|
|
|
LinkedID: githubLinkedID(ghUser),
|
|
|
|
LoginType: database.LoginTypeGithub,
|
|
|
|
AllowSignups: api.GithubOAuth2Config.AllowSignups,
|
|
|
|
Email: verifiedEmail.GetEmail(),
|
|
|
|
Username: ghUser.GetLogin(),
|
2022-09-04 16:44:27 +00:00
|
|
|
AvatarURL: ghUser.GetAvatarURL(),
|
2022-08-22 23:13:46 +00:00
|
|
|
})
|
|
|
|
var httpErr httpError
|
|
|
|
if xerrors.As(err, &httpErr) {
|
2022-09-21 22:07:00 +00:00
|
|
|
httpapi.Write(ctx, rw, httpErr.code, codersdk.Response{
|
2022-08-22 23:13:46 +00:00
|
|
|
Message: httpErr.msg,
|
|
|
|
Detail: httpErr.detail,
|
2022-08-17 23:00:53 +00:00
|
|
|
})
|
|
|
|
return
|
|
|
|
}
|
2022-08-22 23:13:46 +00:00
|
|
|
if err != nil {
|
2022-09-21 22:07:00 +00:00
|
|
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
2022-08-22 23:13:46 +00:00
|
|
|
Message: "Failed to process OAuth login.",
|
|
|
|
Detail: err.Error(),
|
2022-08-17 23:00:53 +00:00
|
|
|
})
|
|
|
|
return
|
2022-04-23 22:58:57 +00:00
|
|
|
}
|
2023-02-06 20:12:50 +00:00
|
|
|
aReq.New = key
|
2023-03-22 19:52:13 +00:00
|
|
|
aReq.UserID = key.UserID
|
2022-04-23 22:58:57 +00:00
|
|
|
|
2022-09-22 22:30:32 +00:00
|
|
|
http.SetCookie(rw, cookie)
|
2022-04-23 22:58:57 +00:00
|
|
|
|
|
|
|
redirect := state.Redirect
|
|
|
|
if redirect == "" {
|
|
|
|
redirect = "/"
|
|
|
|
}
|
|
|
|
http.Redirect(rw, r, redirect, http.StatusTemporaryRedirect)
|
|
|
|
}
|
2022-08-01 04:05:35 +00:00
|
|
|
|
|
|
|
type OIDCConfig struct {
|
|
|
|
httpmw.OAuth2Config
|
|
|
|
|
2023-01-16 22:06:39 +00:00
|
|
|
Provider *oidc.Provider
|
2022-08-01 04:05:35 +00:00
|
|
|
Verifier *oidc.IDTokenVerifier
|
2022-12-05 18:20:53 +00:00
|
|
|
// EmailDomains are the domains to enforce when a user authenticates.
|
|
|
|
EmailDomain []string
|
2022-08-01 04:05:35 +00:00
|
|
|
AllowSignups bool
|
2022-11-25 10:10:09 +00:00
|
|
|
// IgnoreEmailVerified allows ignoring the email_verified claim
|
|
|
|
// from an upstream OIDC provider. See #5065 for context.
|
|
|
|
IgnoreEmailVerified bool
|
2023-01-04 21:16:31 +00:00
|
|
|
// UsernameField selects the claim field to be used as the created user's
|
|
|
|
// username.
|
|
|
|
UsernameField string
|
2023-03-30 08:36:57 +00:00
|
|
|
// EmailField selects the claim field to be used as the created user's
|
|
|
|
// email.
|
|
|
|
EmailField string
|
|
|
|
// AuthURLParams are additional parameters to be passed to the OIDC provider
|
|
|
|
// when requesting an access token.
|
|
|
|
AuthURLParams map[string]string
|
2023-03-10 05:31:38 +00:00
|
|
|
// GroupField selects the claim field to be used as the created user's
|
|
|
|
// groups. If the group field is the empty string, then no group updates
|
|
|
|
// will ever come from the OIDC provider.
|
|
|
|
GroupField string
|
2023-03-21 19:25:45 +00:00
|
|
|
// GroupMapping controls how groups returned by the OIDC provider get mapped
|
|
|
|
// to groups within Coder.
|
|
|
|
// map[oidcGroupName]coderGroupName
|
|
|
|
GroupMapping map[string]string
|
2023-01-31 18:33:25 +00:00
|
|
|
// SignInText is the text to display on the OIDC login button
|
|
|
|
SignInText string
|
|
|
|
// IconURL points to the URL of an icon to display on the OIDC login button
|
|
|
|
IconURL string
|
2022-08-01 04:05:35 +00:00
|
|
|
}
|
|
|
|
|
2023-01-11 13:08:04 +00:00
|
|
|
// @Summary OpenID Connect Callback
|
2023-01-13 11:27:21 +00:00
|
|
|
// @ID openid-connect-callback
|
2023-01-11 13:08:04 +00:00
|
|
|
// @Security CoderSessionToken
|
|
|
|
// @Tags Users
|
|
|
|
// @Success 307
|
|
|
|
// @Router /users/oidc/callback [get]
|
2022-08-01 04:05:35 +00:00
|
|
|
func (api *API) userOIDC(rw http.ResponseWriter, r *http.Request) {
|
2022-08-17 23:00:53 +00:00
|
|
|
var (
|
2023-03-04 20:32:07 +00:00
|
|
|
// userOIDC is a system function.
|
|
|
|
//nolint:gocritic
|
|
|
|
ctx = dbauthz.AsSystemRestricted(r.Context())
|
2023-02-06 20:12:50 +00:00
|
|
|
state = httpmw.OAuth2(r)
|
|
|
|
auditor = api.Auditor.Load()
|
|
|
|
aReq, commitAudit = audit.InitRequest[database.APIKey](rw, &audit.RequestParams{
|
|
|
|
Audit: *auditor,
|
|
|
|
Log: api.Logger,
|
|
|
|
Request: r,
|
|
|
|
Action: database.AuditActionLogin,
|
|
|
|
})
|
2022-08-17 23:00:53 +00:00
|
|
|
)
|
2023-02-06 20:12:50 +00:00
|
|
|
aReq.Old = database.APIKey{}
|
|
|
|
defer commitAudit()
|
2022-08-01 04:05:35 +00:00
|
|
|
|
|
|
|
// See the example here: https://github.com/coreos/go-oidc
|
|
|
|
rawIDToken, ok := state.Token.Extra("id_token").(string)
|
|
|
|
if !ok {
|
2022-09-21 22:07:00 +00:00
|
|
|
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
2022-08-01 04:05:35 +00:00
|
|
|
Message: "id_token not found in response payload. Ensure your OIDC callback is configured correctly!",
|
|
|
|
})
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
2022-08-17 23:00:53 +00:00
|
|
|
idToken, err := api.OIDCConfig.Verifier.Verify(ctx, rawIDToken)
|
2022-08-01 04:05:35 +00:00
|
|
|
if err != nil {
|
2022-09-21 22:07:00 +00:00
|
|
|
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
2022-08-01 04:05:35 +00:00
|
|
|
Message: "Failed to verify OIDC token.",
|
|
|
|
Detail: err.Error(),
|
|
|
|
})
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
2022-09-08 14:06:00 +00:00
|
|
|
// "email_verified" is an optional claim that changes the behavior
|
|
|
|
// of our OIDC handler, so each property must be pulled manually out
|
|
|
|
// of the claim mapping.
|
|
|
|
claims := map[string]interface{}{}
|
2022-08-01 04:05:35 +00:00
|
|
|
err = idToken.Claims(&claims)
|
|
|
|
if err != nil {
|
2022-09-21 22:07:00 +00:00
|
|
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
2022-08-01 04:05:35 +00:00
|
|
|
Message: "Failed to extract OIDC claims.",
|
|
|
|
Detail: err.Error(),
|
|
|
|
})
|
|
|
|
return
|
|
|
|
}
|
2023-01-16 22:06:39 +00:00
|
|
|
|
|
|
|
// Not all claims are necessarily embedded in the `id_token`.
|
|
|
|
// In GitLab, the username is left empty and must be fetched in UserInfo.
|
|
|
|
//
|
|
|
|
// The OIDC specification says claims can be in either place, so we fetch
|
|
|
|
// user info and merge the two claim sets to be sure we have all of
|
|
|
|
// the correct data.
|
|
|
|
userInfo, err := api.OIDCConfig.Provider.UserInfo(ctx, oauth2.StaticTokenSource(state.Token))
|
|
|
|
if err == nil {
|
|
|
|
userInfoClaims := map[string]interface{}{}
|
|
|
|
err = userInfo.Claims(&userInfoClaims)
|
|
|
|
if err != nil {
|
|
|
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
|
|
|
Message: "Failed to unmarshal user info claims.",
|
|
|
|
Detail: err.Error(),
|
|
|
|
})
|
|
|
|
return
|
|
|
|
}
|
|
|
|
for k, v := range userInfoClaims {
|
|
|
|
claims[k] = v
|
|
|
|
}
|
|
|
|
} else if !strings.Contains(err.Error(), "user info endpoint is not supported by this provider") {
|
|
|
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
|
|
|
Message: "Failed to obtain user information claims.",
|
|
|
|
Detail: "The OIDC provider returned no claims as part of the `id_token`. The attempt to fetch claims via the UserInfo endpoint failed: " + err.Error(),
|
|
|
|
})
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
2023-03-17 00:33:45 +00:00
|
|
|
// Log all of the field names returned in the ID token claims, and the
|
|
|
|
// userinfo returned from the provider.
|
|
|
|
{
|
|
|
|
fields := make([]string, 0, len(claims))
|
|
|
|
for f := range claims {
|
|
|
|
fields = append(fields, f)
|
|
|
|
}
|
|
|
|
|
|
|
|
api.Logger.Debug(ctx, "got oidc claims",
|
|
|
|
slog.F("user_info", userInfo),
|
|
|
|
slog.F("claim_fields", fields),
|
|
|
|
)
|
|
|
|
}
|
|
|
|
|
2023-01-04 21:16:31 +00:00
|
|
|
usernameRaw, ok := claims[api.OIDCConfig.UsernameField]
|
2022-10-17 19:14:49 +00:00
|
|
|
var username string
|
|
|
|
if ok {
|
|
|
|
username, _ = usernameRaw.(string)
|
|
|
|
}
|
2023-02-02 19:53:48 +00:00
|
|
|
|
2023-03-30 08:36:57 +00:00
|
|
|
emailRaw, ok := claims[api.OIDCConfig.EmailField]
|
2022-09-08 14:06:00 +00:00
|
|
|
if !ok {
|
2022-10-17 19:14:49 +00:00
|
|
|
// Email is an optional claim in OIDC and
|
|
|
|
// instead the email is frequently sent in
|
|
|
|
// "preferred_username". See:
|
|
|
|
// https://github.com/coder/coder/issues/4472
|
|
|
|
_, err = mail.ParseAddress(username)
|
|
|
|
if err != nil {
|
|
|
|
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
|
|
|
Message: "No email found in OIDC payload!",
|
|
|
|
})
|
|
|
|
return
|
|
|
|
}
|
|
|
|
emailRaw = username
|
2022-08-01 04:05:35 +00:00
|
|
|
}
|
2023-02-02 19:53:48 +00:00
|
|
|
|
2022-09-08 14:06:00 +00:00
|
|
|
email, ok := emailRaw.(string)
|
|
|
|
if !ok {
|
2022-09-21 22:07:00 +00:00
|
|
|
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
2022-09-08 14:06:00 +00:00
|
|
|
Message: fmt.Sprintf("Email in OIDC payload isn't a string. Got: %t", emailRaw),
|
2022-08-01 04:05:35 +00:00
|
|
|
})
|
|
|
|
return
|
|
|
|
}
|
2023-02-02 19:53:48 +00:00
|
|
|
|
2022-09-08 14:06:00 +00:00
|
|
|
verifiedRaw, ok := claims["email_verified"]
|
|
|
|
if ok {
|
|
|
|
verified, ok := verifiedRaw.(bool)
|
|
|
|
if ok && !verified {
|
2022-11-25 10:10:09 +00:00
|
|
|
if !api.OIDCConfig.IgnoreEmailVerified {
|
|
|
|
httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{
|
|
|
|
Message: fmt.Sprintf("Verify the %q email address on your OIDC provider to authenticate!", email),
|
|
|
|
})
|
|
|
|
return
|
|
|
|
}
|
|
|
|
api.Logger.Warn(ctx, "allowing unverified oidc email %q")
|
2022-09-08 14:06:00 +00:00
|
|
|
}
|
|
|
|
}
|
2023-02-02 19:53:48 +00:00
|
|
|
|
2023-03-10 05:31:38 +00:00
|
|
|
var usingGroups bool
|
2023-02-02 19:53:48 +00:00
|
|
|
var groups []string
|
2023-03-10 05:31:38 +00:00
|
|
|
// If the GroupField is the empty string, then groups from OIDC are not used.
|
|
|
|
// This is so we can support manual group assignment.
|
|
|
|
if api.OIDCConfig.GroupField != "" {
|
|
|
|
usingGroups = true
|
|
|
|
groupsRaw, ok := claims[api.OIDCConfig.GroupField]
|
|
|
|
if ok && api.OIDCConfig.GroupField != "" {
|
|
|
|
// Convert the []interface{} we get to a []string.
|
|
|
|
groupsInterface, ok := groupsRaw.([]interface{})
|
|
|
|
if ok {
|
2023-03-17 00:33:45 +00:00
|
|
|
api.Logger.Debug(ctx, "groups returned in oidc claims",
|
|
|
|
slog.F("len", len(groupsInterface)),
|
|
|
|
slog.F("groups", groupsInterface),
|
|
|
|
)
|
|
|
|
|
2023-03-10 05:31:38 +00:00
|
|
|
for _, groupInterface := range groupsInterface {
|
|
|
|
group, ok := groupInterface.(string)
|
|
|
|
if !ok {
|
|
|
|
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
|
|
|
Message: fmt.Sprintf("Invalid group type. Expected string, got: %t", emailRaw),
|
|
|
|
})
|
|
|
|
return
|
|
|
|
}
|
2023-03-21 19:25:45 +00:00
|
|
|
|
|
|
|
if mappedGroup, ok := api.OIDCConfig.GroupMapping[group]; ok {
|
|
|
|
group = mappedGroup
|
|
|
|
}
|
|
|
|
|
2023-03-10 05:31:38 +00:00
|
|
|
groups = append(groups, group)
|
2023-02-02 19:53:48 +00:00
|
|
|
}
|
2023-03-17 00:33:45 +00:00
|
|
|
} else {
|
|
|
|
api.Logger.Debug(ctx, "groups field was an unknown type",
|
|
|
|
slog.F("type", fmt.Sprintf("%T", groupsRaw)),
|
|
|
|
)
|
2023-02-02 19:53:48 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2022-08-01 04:05:35 +00:00
|
|
|
// The username is a required property in Coder. We make a best-effort
|
|
|
|
// attempt at using what the claims provide, but if that fails we will
|
|
|
|
// generate a random username.
|
2022-11-10 20:51:09 +00:00
|
|
|
usernameValid := httpapi.NameValid(username)
|
2022-10-25 00:46:24 +00:00
|
|
|
if usernameValid != nil {
|
2022-08-01 04:05:35 +00:00
|
|
|
// If no username is provided, we can default to use the email address.
|
|
|
|
// This will be converted in the from function below, so it's safe
|
|
|
|
// to keep the domain.
|
2022-09-08 14:06:00 +00:00
|
|
|
if username == "" {
|
|
|
|
username = email
|
2022-08-01 04:05:35 +00:00
|
|
|
}
|
2022-09-08 14:06:00 +00:00
|
|
|
username = httpapi.UsernameFrom(username)
|
2022-08-01 04:05:35 +00:00
|
|
|
}
|
2023-02-02 19:53:48 +00:00
|
|
|
|
2022-12-05 18:20:53 +00:00
|
|
|
if len(api.OIDCConfig.EmailDomain) > 0 {
|
|
|
|
ok = false
|
|
|
|
for _, domain := range api.OIDCConfig.EmailDomain {
|
|
|
|
if strings.HasSuffix(strings.ToLower(email), strings.ToLower(domain)) {
|
|
|
|
ok = true
|
|
|
|
break
|
|
|
|
}
|
|
|
|
}
|
|
|
|
if !ok {
|
2022-09-21 22:07:00 +00:00
|
|
|
httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{
|
2022-12-05 18:20:53 +00:00
|
|
|
Message: fmt.Sprintf("Your email %q is not in domains %q !", email, api.OIDCConfig.EmailDomain),
|
2022-08-01 04:05:35 +00:00
|
|
|
})
|
|
|
|
return
|
|
|
|
}
|
|
|
|
}
|
2023-02-02 19:53:48 +00:00
|
|
|
|
2022-09-08 14:06:00 +00:00
|
|
|
var picture string
|
|
|
|
pictureRaw, ok := claims["picture"]
|
|
|
|
if ok {
|
|
|
|
picture, _ = pictureRaw.(string)
|
|
|
|
}
|
2022-08-01 04:05:35 +00:00
|
|
|
|
2023-02-06 20:12:50 +00:00
|
|
|
user, link, err := findLinkedUser(ctx, api.Database, oidcLinkedID(idToken), email)
|
|
|
|
if err != nil {
|
|
|
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
|
|
|
Message: "Failed to find linked user.",
|
|
|
|
Detail: err.Error(),
|
|
|
|
})
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
cookie, key, err := api.oauthLogin(r, oauthLoginParams{
|
|
|
|
User: user,
|
|
|
|
Link: link,
|
2022-08-22 23:13:46 +00:00
|
|
|
State: state,
|
|
|
|
LinkedID: oidcLinkedID(idToken),
|
|
|
|
LoginType: database.LoginTypeOIDC,
|
|
|
|
AllowSignups: api.OIDCConfig.AllowSignups,
|
2022-09-08 14:06:00 +00:00
|
|
|
Email: email,
|
|
|
|
Username: username,
|
|
|
|
AvatarURL: picture,
|
2023-03-10 05:31:38 +00:00
|
|
|
UsingGroups: usingGroups,
|
2023-02-02 19:53:48 +00:00
|
|
|
Groups: groups,
|
2022-08-22 23:13:46 +00:00
|
|
|
})
|
|
|
|
var httpErr httpError
|
|
|
|
if xerrors.As(err, &httpErr) {
|
2022-09-21 22:07:00 +00:00
|
|
|
httpapi.Write(ctx, rw, httpErr.code, codersdk.Response{
|
2022-08-22 23:13:46 +00:00
|
|
|
Message: httpErr.msg,
|
|
|
|
Detail: httpErr.detail,
|
|
|
|
})
|
|
|
|
return
|
|
|
|
}
|
2022-08-17 23:00:53 +00:00
|
|
|
if err != nil {
|
2022-09-21 22:07:00 +00:00
|
|
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
2022-08-22 23:13:46 +00:00
|
|
|
Message: "Failed to process OAuth login.",
|
2022-08-17 23:00:53 +00:00
|
|
|
Detail: err.Error(),
|
|
|
|
})
|
|
|
|
return
|
|
|
|
}
|
2023-02-06 20:12:50 +00:00
|
|
|
aReq.New = key
|
2023-03-22 19:52:13 +00:00
|
|
|
aReq.UserID = key.UserID
|
2022-08-17 23:00:53 +00:00
|
|
|
|
2022-09-22 22:30:32 +00:00
|
|
|
http.SetCookie(rw, cookie)
|
2022-08-22 23:13:46 +00:00
|
|
|
|
|
|
|
redirect := state.Redirect
|
|
|
|
if redirect == "" {
|
|
|
|
redirect = "/"
|
2022-08-17 23:00:53 +00:00
|
|
|
}
|
2022-08-22 23:13:46 +00:00
|
|
|
http.Redirect(rw, r, redirect, http.StatusTemporaryRedirect)
|
|
|
|
}
|
2022-08-17 23:00:53 +00:00
|
|
|
|
2022-08-22 23:13:46 +00:00
|
|
|
type oauthLoginParams struct {
|
2023-02-06 20:12:50 +00:00
|
|
|
User database.User
|
|
|
|
Link database.UserLink
|
2022-08-22 23:13:46 +00:00
|
|
|
State httpmw.OAuth2State
|
|
|
|
LinkedID string
|
|
|
|
LoginType database.LoginType
|
|
|
|
|
|
|
|
// The following are necessary in order to
|
|
|
|
// create new users.
|
|
|
|
AllowSignups bool
|
|
|
|
Email string
|
|
|
|
Username string
|
2022-09-04 16:44:27 +00:00
|
|
|
AvatarURL string
|
2023-03-10 05:31:38 +00:00
|
|
|
// Is UsingGroups is true, then the user will be assigned
|
|
|
|
// to the Groups provided.
|
|
|
|
UsingGroups bool
|
|
|
|
Groups []string
|
2022-08-22 23:13:46 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
type httpError struct {
|
|
|
|
code int
|
|
|
|
msg string
|
|
|
|
detail string
|
|
|
|
}
|
|
|
|
|
|
|
|
func (e httpError) Error() string {
|
|
|
|
if e.detail != "" {
|
|
|
|
return e.detail
|
2022-08-17 23:00:53 +00:00
|
|
|
}
|
|
|
|
|
2022-08-22 23:13:46 +00:00
|
|
|
return e.msg
|
|
|
|
}
|
2022-08-17 23:00:53 +00:00
|
|
|
|
2023-02-06 20:12:50 +00:00
|
|
|
func (api *API) oauthLogin(r *http.Request, params oauthLoginParams) (*http.Cookie, database.APIKey, error) {
|
2022-08-22 23:13:46 +00:00
|
|
|
var (
|
|
|
|
ctx = r.Context()
|
|
|
|
user database.User
|
|
|
|
)
|
|
|
|
|
|
|
|
err := api.Database.InTx(func(tx database.Store) error {
|
|
|
|
var (
|
|
|
|
link database.UserLink
|
|
|
|
err error
|
|
|
|
)
|
|
|
|
|
2023-02-06 20:12:50 +00:00
|
|
|
user = params.User
|
|
|
|
link = params.Link
|
2022-08-22 23:13:46 +00:00
|
|
|
|
|
|
|
if user.ID == uuid.Nil && !params.AllowSignups {
|
|
|
|
return httpError{
|
|
|
|
code: http.StatusForbidden,
|
|
|
|
msg: fmt.Sprintf("Signups are not allowed for login type %q", params.LoginType),
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
if user.ID != uuid.Nil && user.LoginType != params.LoginType {
|
|
|
|
return 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,
|
|
|
|
),
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// This can happen if a user is a built-in user but is signing in
|
|
|
|
// with OIDC for the first time.
|
|
|
|
if user.ID == uuid.Nil {
|
|
|
|
var organizationID uuid.UUID
|
2023-02-14 14:27:06 +00:00
|
|
|
//nolint:gocritic
|
2023-02-15 16:14:37 +00:00
|
|
|
organizations, _ := tx.GetOrganizations(dbauthz.AsSystemRestricted(ctx))
|
2022-08-22 23:13:46 +00:00
|
|
|
if len(organizations) > 0 {
|
|
|
|
// Add the user to the first organization. Once multi-organization
|
|
|
|
// support is added, we should enable a configuration map of user
|
|
|
|
// email to organization.
|
|
|
|
organizationID = organizations[0].ID
|
|
|
|
}
|
|
|
|
|
2023-02-14 14:27:06 +00:00
|
|
|
//nolint:gocritic
|
2023-02-15 16:14:37 +00:00
|
|
|
_, err := tx.GetUserByEmailOrUsername(dbauthz.AsSystemRestricted(ctx), database.GetUserByEmailOrUsernameParams{
|
2022-10-18 03:07:11 +00:00
|
|
|
Username: params.Username,
|
|
|
|
})
|
|
|
|
if err == nil {
|
|
|
|
var (
|
|
|
|
original = params.Username
|
|
|
|
validUsername bool
|
|
|
|
)
|
|
|
|
for i := 0; i < 10; i++ {
|
|
|
|
alternate := fmt.Sprintf("%s-%s", original, namesgenerator.GetRandomName(1))
|
|
|
|
|
|
|
|
params.Username = httpapi.UsernameFrom(alternate)
|
|
|
|
|
2023-02-14 14:27:06 +00:00
|
|
|
//nolint:gocritic
|
2023-02-15 16:14:37 +00:00
|
|
|
_, err := tx.GetUserByEmailOrUsername(dbauthz.AsSystemRestricted(ctx), database.GetUserByEmailOrUsernameParams{
|
2022-10-18 03:07:11 +00:00
|
|
|
Username: params.Username,
|
|
|
|
})
|
|
|
|
if xerrors.Is(err, sql.ErrNoRows) {
|
|
|
|
validUsername = true
|
|
|
|
break
|
|
|
|
}
|
|
|
|
if err != nil {
|
|
|
|
return xerrors.Errorf("get user by email/username: %w", err)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
if !validUsername {
|
|
|
|
return httpError{
|
|
|
|
code: http.StatusConflict,
|
|
|
|
msg: fmt.Sprintf("exhausted alternatives for taken username %q", original),
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2023-02-14 14:27:06 +00:00
|
|
|
//nolint:gocritic
|
2023-02-15 16:14:37 +00:00
|
|
|
user, _, err = api.CreateUser(dbauthz.AsSystemRestricted(ctx), tx, CreateUserRequest{
|
2022-08-22 23:13:46 +00:00
|
|
|
CreateUserRequest: codersdk.CreateUserRequest{
|
|
|
|
Email: params.Email,
|
|
|
|
Username: params.Username,
|
|
|
|
OrganizationID: organizationID,
|
|
|
|
},
|
2022-09-07 15:37:15 +00:00
|
|
|
LoginType: params.LoginType,
|
2022-08-17 23:00:53 +00:00
|
|
|
})
|
2022-08-22 23:13:46 +00:00
|
|
|
if err != nil {
|
|
|
|
return xerrors.Errorf("create user: %w", err)
|
|
|
|
}
|
2022-08-17 23:00:53 +00:00
|
|
|
}
|
|
|
|
|
2022-08-22 23:13:46 +00:00
|
|
|
if link.UserID == uuid.Nil {
|
2023-02-14 14:27:06 +00:00
|
|
|
//nolint:gocritic
|
2023-02-15 16:14:37 +00:00
|
|
|
link, err = tx.InsertUserLink(dbauthz.AsSystemRestricted(ctx), database.InsertUserLinkParams{
|
2022-08-22 23:13:46 +00:00
|
|
|
UserID: user.ID,
|
|
|
|
LoginType: params.LoginType,
|
|
|
|
LinkedID: params.LinkedID,
|
|
|
|
OAuthAccessToken: params.State.Token.AccessToken,
|
|
|
|
OAuthRefreshToken: params.State.Token.RefreshToken,
|
|
|
|
OAuthExpiry: params.State.Token.Expiry,
|
2022-08-17 23:00:53 +00:00
|
|
|
})
|
2022-08-22 23:13:46 +00:00
|
|
|
if err != nil {
|
|
|
|
return xerrors.Errorf("insert user link: %w", err)
|
|
|
|
}
|
2022-08-17 23:00:53 +00:00
|
|
|
}
|
|
|
|
|
2022-08-22 23:13:46 +00:00
|
|
|
if link.UserID != uuid.Nil {
|
2023-02-14 14:27:06 +00:00
|
|
|
//nolint:gocritic
|
2023-02-15 16:14:37 +00:00
|
|
|
link, err = tx.UpdateUserLink(dbauthz.AsSystemRestricted(ctx), database.UpdateUserLinkParams{
|
2022-08-22 23:13:46 +00:00
|
|
|
UserID: user.ID,
|
|
|
|
LoginType: params.LoginType,
|
|
|
|
OAuthAccessToken: params.State.Token.AccessToken,
|
|
|
|
OAuthRefreshToken: params.State.Token.RefreshToken,
|
|
|
|
OAuthExpiry: params.State.Token.Expiry,
|
2022-08-18 04:06:03 +00:00
|
|
|
})
|
2022-08-22 23:13:46 +00:00
|
|
|
if err != nil {
|
|
|
|
return xerrors.Errorf("update user link: %w", err)
|
|
|
|
}
|
2022-08-18 04:06:03 +00:00
|
|
|
}
|
|
|
|
|
2023-02-02 19:53:48 +00:00
|
|
|
// Ensure groups are correct.
|
2023-03-10 05:31:38 +00:00
|
|
|
if params.UsingGroups {
|
2023-02-14 14:27:06 +00:00
|
|
|
//nolint:gocritic
|
2023-02-15 16:14:37 +00:00
|
|
|
err := api.Options.SetUserGroups(dbauthz.AsSystemRestricted(ctx), tx, user.ID, params.Groups)
|
2023-02-02 19:53:48 +00:00
|
|
|
if err != nil {
|
|
|
|
return xerrors.Errorf("set user groups: %w", err)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2022-09-04 16:44:27 +00:00
|
|
|
needsUpdate := false
|
|
|
|
if user.AvatarURL.String != params.AvatarURL {
|
|
|
|
user.AvatarURL = sql.NullString{
|
|
|
|
String: params.AvatarURL,
|
|
|
|
Valid: true,
|
|
|
|
}
|
|
|
|
needsUpdate = true
|
|
|
|
}
|
|
|
|
|
2022-08-22 23:13:46 +00:00
|
|
|
// If the upstream email or username has changed we should mirror
|
|
|
|
// that in Coder. Many enterprises use a user's email/username as
|
|
|
|
// security auditing fields so they need to stay synced.
|
|
|
|
// NOTE: username updating has been halted since it can have infrastructure
|
|
|
|
// provisioning consequences (updates to usernames may delete persistent
|
|
|
|
// resources such as user home volumes).
|
|
|
|
if user.Email != params.Email {
|
2022-09-04 16:44:27 +00:00
|
|
|
user.Email = params.Email
|
|
|
|
needsUpdate = true
|
|
|
|
}
|
|
|
|
|
|
|
|
if needsUpdate {
|
2022-08-22 23:13:46 +00:00
|
|
|
// TODO(JonA): Since we're processing updates to a user's upstream
|
|
|
|
// email/username, it's possible for a different built-in user to
|
|
|
|
// have already claimed the username.
|
|
|
|
// In such cases in the current implementation this user can now no
|
|
|
|
// longer sign in until an administrator finds the offending built-in
|
|
|
|
// user and changes their username.
|
2023-02-14 14:27:06 +00:00
|
|
|
//nolint:gocritic
|
2023-02-15 16:14:37 +00:00
|
|
|
user, err = tx.UpdateUserProfile(dbauthz.AsSystemRestricted(ctx), database.UpdateUserProfileParams{
|
2022-08-22 23:13:46 +00:00
|
|
|
ID: user.ID,
|
2022-09-04 16:44:27 +00:00
|
|
|
Email: user.Email,
|
2022-08-22 23:13:46 +00:00
|
|
|
Username: user.Username,
|
|
|
|
UpdatedAt: database.Now(),
|
2022-09-04 16:44:27 +00:00
|
|
|
AvatarURL: user.AvatarURL,
|
2022-08-17 23:00:53 +00:00
|
|
|
})
|
2022-08-22 23:13:46 +00:00
|
|
|
if err != nil {
|
|
|
|
return xerrors.Errorf("update user profile: %w", err)
|
|
|
|
}
|
2022-08-17 23:00:53 +00:00
|
|
|
}
|
2022-08-22 23:13:46 +00:00
|
|
|
|
|
|
|
return nil
|
2022-11-14 17:57:33 +00:00
|
|
|
}, nil)
|
2022-08-22 23:13:46 +00:00
|
|
|
if err != nil {
|
2023-02-06 20:12:50 +00:00
|
|
|
return nil, database.APIKey{}, xerrors.Errorf("in tx: %w", err)
|
2022-08-17 23:00:53 +00:00
|
|
|
}
|
|
|
|
|
2023-02-14 14:27:06 +00:00
|
|
|
//nolint:gocritic
|
2023-02-15 16:14:37 +00:00
|
|
|
cookie, key, err := api.createAPIKey(dbauthz.AsSystemRestricted(ctx), createAPIKeyParams{
|
2022-09-21 22:07:00 +00:00
|
|
|
UserID: user.ID,
|
|
|
|
LoginType: params.LoginType,
|
|
|
|
RemoteAddr: r.RemoteAddr,
|
2022-08-01 04:05:35 +00:00
|
|
|
})
|
2022-08-22 23:13:46 +00:00
|
|
|
if err != nil {
|
2023-02-06 20:12:50 +00:00
|
|
|
return nil, database.APIKey{}, xerrors.Errorf("create API key: %w", err)
|
2022-08-01 04:05:35 +00:00
|
|
|
}
|
|
|
|
|
2023-02-06 20:12:50 +00:00
|
|
|
return cookie, *key, nil
|
2022-08-01 04:05:35 +00:00
|
|
|
}
|
2022-08-17 23:00:53 +00:00
|
|
|
|
|
|
|
// githubLinkedID returns the unique ID for a GitHub user.
|
|
|
|
func githubLinkedID(u *github.User) string {
|
|
|
|
return strconv.FormatInt(u.GetID(), 10)
|
|
|
|
}
|
|
|
|
|
|
|
|
// oidcLinkedID returns the uniqued ID for an OIDC user.
|
|
|
|
// See https://openid.net/specs/openid-connect-core-1_0.html#ClaimStability .
|
|
|
|
func oidcLinkedID(tok *oidc.IDToken) string {
|
|
|
|
return strings.Join([]string{tok.Issuer, tok.Subject}, "||")
|
|
|
|
}
|
|
|
|
|
|
|
|
// findLinkedUser tries to find a user by their unique OAuth-linked ID.
|
|
|
|
// If it doesn't not find it, it returns the user by their email.
|
|
|
|
func findLinkedUser(ctx context.Context, db database.Store, linkedID string, emails ...string) (database.User, database.UserLink, error) {
|
|
|
|
var (
|
|
|
|
user database.User
|
|
|
|
link database.UserLink
|
|
|
|
)
|
|
|
|
link, err := db.GetUserLinkByLinkedID(ctx, linkedID)
|
|
|
|
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
|
|
|
return user, link, xerrors.Errorf("get user auth by linked ID: %w", err)
|
|
|
|
}
|
|
|
|
|
|
|
|
if err == nil {
|
|
|
|
user, err = db.GetUserByID(ctx, link.UserID)
|
|
|
|
if err != nil {
|
|
|
|
return database.User{}, database.UserLink{}, xerrors.Errorf("get user by id: %w", err)
|
|
|
|
}
|
2022-09-13 12:33:35 +00:00
|
|
|
if !user.Deleted {
|
|
|
|
return user, link, nil
|
|
|
|
}
|
|
|
|
// If the user was deleted, act as if no account link exists.
|
|
|
|
user = database.User{}
|
2022-08-17 23:00:53 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
for _, email := range emails {
|
|
|
|
user, err = db.GetUserByEmailOrUsername(ctx, database.GetUserByEmailOrUsernameParams{
|
|
|
|
Email: email,
|
|
|
|
})
|
|
|
|
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
|
|
|
return user, link, xerrors.Errorf("get user by email: %w", err)
|
|
|
|
}
|
|
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
break
|
|
|
|
}
|
|
|
|
|
|
|
|
if user.ID == uuid.Nil {
|
|
|
|
// No user found.
|
|
|
|
return database.User{}, database.UserLink{}, nil
|
|
|
|
}
|
|
|
|
|
|
|
|
// LEGACY: This is annoying but we have to search for the user_link
|
|
|
|
// again except this time we search by user_id and login_type. It's
|
|
|
|
// possible that a user_link exists without a populated 'linked_id'.
|
|
|
|
link, err = db.GetUserLinkByUserIDLoginType(ctx, database.GetUserLinkByUserIDLoginTypeParams{
|
|
|
|
UserID: user.ID,
|
|
|
|
LoginType: user.LoginType,
|
|
|
|
})
|
|
|
|
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
|
|
|
return database.User{}, database.UserLink{}, xerrors.Errorf("get user link by user id and login type: %w", err)
|
|
|
|
}
|
|
|
|
|
|
|
|
return user, link, nil
|
|
|
|
}
|