mirror of https://github.com/coder/coder.git
feat: audit login (#5925)
* added migration for api key resource * sort of working * auditing login * passing the correct user id * added and fixed tests * gen documentation * formatting and lint * lint * audit Github oauth and write tests * audit oauth and write tests * added defer fn for login error auditing * fixed test * feat: audit logout (#5998) * Update coderd/userauth.go Co-authored-by: Steven Masley <Emyrk@users.noreply.github.com> * fix test * bypassing diff generation if login/logout * lint --------- Co-authored-by: Steven Masley <Emyrk@users.noreply.github.com>
This commit is contained in:
parent
060eeed5c3
commit
46fe59f5e7
|
@ -5350,14 +5350,18 @@ const docTemplate = `{
|
|||
"write",
|
||||
"delete",
|
||||
"start",
|
||||
"stop"
|
||||
"stop",
|
||||
"login",
|
||||
"logout"
|
||||
],
|
||||
"x-enum-varnames": [
|
||||
"AuditActionCreate",
|
||||
"AuditActionWrite",
|
||||
"AuditActionDelete",
|
||||
"AuditActionStart",
|
||||
"AuditActionStop"
|
||||
"AuditActionStop",
|
||||
"AuditActionLogin",
|
||||
"AuditActionLogout"
|
||||
]
|
||||
},
|
||||
"codersdk.AuditDiff": {
|
||||
|
|
|
@ -4732,13 +4732,15 @@
|
|||
},
|
||||
"codersdk.AuditAction": {
|
||||
"type": "string",
|
||||
"enum": ["create", "write", "delete", "start", "stop"],
|
||||
"enum": ["create", "write", "delete", "start", "stop", "login", "logout"],
|
||||
"x-enum-varnames": [
|
||||
"AuditActionCreate",
|
||||
"AuditActionWrite",
|
||||
"AuditActionDelete",
|
||||
"AuditActionStart",
|
||||
"AuditActionStop"
|
||||
"AuditActionStop",
|
||||
"AuditActionLogin",
|
||||
"AuditActionLogout"
|
||||
]
|
||||
},
|
||||
"codersdk.AuditDiff": {
|
||||
|
|
|
@ -70,7 +70,7 @@ func (api *API) postToken(rw http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
cookie, err := api.createAPIKey(ctx, createAPIKeyParams{
|
||||
cookie, _, err := api.createAPIKey(ctx, createAPIKeyParams{
|
||||
UserID: user.ID,
|
||||
LoginType: database.LoginTypeToken,
|
||||
ExpiresAt: database.Now().Add(lifeTime),
|
||||
|
@ -108,7 +108,7 @@ func (api *API) postAPIKey(rw http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
lifeTime := time.Hour * 24 * 7
|
||||
cookie, err := api.createAPIKey(ctx, createAPIKeyParams{
|
||||
cookie, _, err := api.createAPIKey(ctx, createAPIKeyParams{
|
||||
UserID: user.ID,
|
||||
LoginType: database.LoginTypePassword,
|
||||
RemoteAddr: r.RemoteAddr,
|
||||
|
@ -281,10 +281,10 @@ func (api *API) validateAPIKeyLifetime(lifetime time.Duration) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func (api *API) createAPIKey(ctx context.Context, params createAPIKeyParams) (*http.Cookie, error) {
|
||||
func (api *API) createAPIKey(ctx context.Context, params createAPIKeyParams) (*http.Cookie, *database.APIKey, error) {
|
||||
keyID, keySecret, err := GenerateAPIKeyIDSecret()
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("generate API key: %w", err)
|
||||
return nil, nil, xerrors.Errorf("generate API key: %w", err)
|
||||
}
|
||||
hashed := sha256.Sum256([]byte(keySecret))
|
||||
|
||||
|
@ -315,7 +315,7 @@ func (api *API) createAPIKey(ctx context.Context, params createAPIKeyParams) (*h
|
|||
switch scope {
|
||||
case database.APIKeyScopeAll, database.APIKeyScopeApplicationConnect:
|
||||
default:
|
||||
return nil, xerrors.Errorf("invalid API key scope: %q", scope)
|
||||
return nil, nil, xerrors.Errorf("invalid API key scope: %q", scope)
|
||||
}
|
||||
|
||||
key, err := api.Database.InsertAPIKey(ctx, database.InsertAPIKeyParams{
|
||||
|
@ -338,7 +338,7 @@ func (api *API) createAPIKey(ctx context.Context, params createAPIKeyParams) (*h
|
|||
Scope: scope,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("insert API key: %w", err)
|
||||
return nil, nil, xerrors.Errorf("insert API key: %w", err)
|
||||
}
|
||||
|
||||
api.Telemetry.Report(&telemetry.Snapshot{
|
||||
|
@ -354,5 +354,5 @@ func (api *API) createAPIKey(ctx context.Context, params createAPIKeyParams) (*h
|
|||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Secure: api.SecureAuthCookie,
|
||||
}, nil
|
||||
}, &key, nil
|
||||
}
|
||||
|
|
|
@ -264,6 +264,12 @@ func auditLogDescription(alog database.GetAuditLogsOffsetRow, additionalFields a
|
|||
codersdk.AuditAction(alog.Action).Friendly(),
|
||||
)
|
||||
|
||||
// API Key resources do not have targets and follow the below format:
|
||||
// "User {logged in | logged out}"
|
||||
if alog.ResourceType == database.ResourceTypeApiKey {
|
||||
return str
|
||||
}
|
||||
|
||||
// Strings for starting/stopping workspace builds follow the below format:
|
||||
// "{user | 'Coder automatically'} started build #{build_number} for workspace {target}"
|
||||
// where target is a workspace (name) instead of a workspace build
|
||||
|
@ -488,6 +494,10 @@ func actionFromString(actionString string) string {
|
|||
return actionString
|
||||
case codersdk.AuditActionStop:
|
||||
return actionString
|
||||
case codersdk.AuditActionLogin:
|
||||
return actionString
|
||||
case codersdk.AuditActionLogout:
|
||||
return actionString
|
||||
default:
|
||||
}
|
||||
return ""
|
||||
|
|
|
@ -31,6 +31,10 @@ type Request[T Auditable] struct {
|
|||
|
||||
Old T
|
||||
New T
|
||||
|
||||
// This optional field can be passed in when the userID cannot be determined from the API Key
|
||||
// such as in the case of login, when the audit log is created prior the API Key's existence.
|
||||
UserID uuid.UUID
|
||||
}
|
||||
|
||||
type BuildAuditParams[T Auditable] struct {
|
||||
|
@ -64,6 +68,9 @@ func ResourceTarget[T Auditable](tgt T) string {
|
|||
return typed.PublicKey
|
||||
case database.AuditableGroup:
|
||||
return typed.Group.Name
|
||||
case database.APIKey:
|
||||
// this isn't used
|
||||
return ""
|
||||
default:
|
||||
panic(fmt.Sprintf("unknown resource %T", tgt))
|
||||
}
|
||||
|
@ -85,6 +92,8 @@ func ResourceID[T Auditable](tgt T) uuid.UUID {
|
|||
return typed.UserID
|
||||
case database.AuditableGroup:
|
||||
return typed.Group.ID
|
||||
case database.APIKey:
|
||||
return typed.UserID
|
||||
default:
|
||||
panic(fmt.Sprintf("unknown resource %T", tgt))
|
||||
}
|
||||
|
@ -106,6 +115,8 @@ func ResourceType[T Auditable](tgt T) database.ResourceType {
|
|||
return database.ResourceTypeGitSshKey
|
||||
case database.AuditableGroup:
|
||||
return database.ResourceTypeGroup
|
||||
case database.APIKey:
|
||||
return database.ResourceTypeApiKey
|
||||
default:
|
||||
panic(fmt.Sprintf("unknown resource %T", tgt))
|
||||
}
|
||||
|
@ -130,7 +141,14 @@ func InitRequest[T Auditable](w http.ResponseWriter, p *RequestParams) (*Request
|
|||
|
||||
// If no resources were provided, there's nothing we can audit.
|
||||
if ResourceID(req.Old) == uuid.Nil && ResourceID(req.New) == uuid.Nil {
|
||||
return
|
||||
// If the request action is a login or logout, we always want to audit it even if
|
||||
// there is no diff. This is so we can capture events where an API Key is never created
|
||||
// because an unknown user fails to login.
|
||||
// TODO: introduce the concept of an anonymous user so we always have a userID even
|
||||
// when dealing with a mystery user. https://github.com/coder/coder/issues/6054
|
||||
if req.params.Action != database.AuditActionLogin && req.params.Action != database.AuditActionLogout {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
var diffRaw = []byte("{}")
|
||||
|
@ -150,16 +168,24 @@ func InitRequest[T Auditable](w http.ResponseWriter, p *RequestParams) (*Request
|
|||
p.AdditionalFields = json.RawMessage("{}")
|
||||
}
|
||||
|
||||
var userID uuid.UUID
|
||||
key, ok := httpmw.APIKeyOptional(p.Request)
|
||||
if ok {
|
||||
userID = key.UserID
|
||||
} else {
|
||||
userID = req.UserID
|
||||
}
|
||||
|
||||
ip := parseIP(p.Request.RemoteAddr)
|
||||
auditLog := database.AuditLog{
|
||||
ID: uuid.New(),
|
||||
Time: database.Now(),
|
||||
UserID: httpmw.APIKey(p.Request).UserID,
|
||||
UserID: userID,
|
||||
Ip: ip,
|
||||
UserAgent: sql.NullString{String: p.Request.UserAgent(), Valid: true},
|
||||
ResourceType: either(req.Old, req.New, ResourceType[T]),
|
||||
ResourceID: either(req.Old, req.New, ResourceID[T]),
|
||||
ResourceTarget: either(req.Old, req.New, ResourceTarget[T]),
|
||||
ResourceType: either(req.Old, req.New, ResourceType[T], req.params.Action),
|
||||
ResourceID: either(req.Old, req.New, ResourceID[T], req.params.Action),
|
||||
ResourceTarget: either(req.Old, req.New, ResourceTarget[T], req.params.Action),
|
||||
Action: p.Action,
|
||||
Diff: diffRaw,
|
||||
StatusCode: int32(sw.Status),
|
||||
|
@ -202,9 +228,9 @@ func BuildAudit[T Auditable](ctx context.Context, p *BuildAuditParams[T]) {
|
|||
UserID: p.UserID,
|
||||
Ip: ip,
|
||||
UserAgent: sql.NullString{},
|
||||
ResourceType: either(p.Old, p.New, ResourceType[T]),
|
||||
ResourceID: either(p.Old, p.New, ResourceID[T]),
|
||||
ResourceTarget: either(p.Old, p.New, ResourceTarget[T]),
|
||||
ResourceType: either(p.Old, p.New, ResourceType[T], p.Action),
|
||||
ResourceID: either(p.Old, p.New, ResourceID[T], p.Action),
|
||||
ResourceTarget: either(p.Old, p.New, ResourceTarget[T], p.Action),
|
||||
Action: p.Action,
|
||||
Diff: diffRaw,
|
||||
StatusCode: int32(p.Status),
|
||||
|
@ -221,11 +247,15 @@ func BuildAudit[T Auditable](ctx context.Context, p *BuildAuditParams[T]) {
|
|||
}
|
||||
}
|
||||
|
||||
func either[T Auditable, R any](old, new T, fn func(T) R) R {
|
||||
func either[T Auditable, R any](old, new T, fn func(T) R, auditAction database.AuditAction) R {
|
||||
if ResourceID(new) != uuid.Nil {
|
||||
return fn(new)
|
||||
} else if ResourceID(old) != uuid.Nil {
|
||||
return fn(old)
|
||||
} else if auditAction == database.AuditActionLogin || auditAction == database.AuditActionLogout {
|
||||
// If the request action is a login or logout, we always want to audit it even if
|
||||
// there is no diff. See the comment in audit.InitRequest for more detail.
|
||||
return fn(old)
|
||||
} else {
|
||||
panic("both old and new are nil")
|
||||
}
|
||||
|
|
|
@ -16,7 +16,9 @@ CREATE TYPE audit_action AS ENUM (
|
|||
'write',
|
||||
'delete',
|
||||
'start',
|
||||
'stop'
|
||||
'stop',
|
||||
'login',
|
||||
'logout'
|
||||
);
|
||||
|
||||
CREATE TYPE build_reason AS ENUM (
|
||||
|
|
|
@ -0,0 +1,2 @@
|
|||
-- It's not possible to drop enum values from enum types, so the UP has "IF NOT
|
||||
-- EXISTS".
|
|
@ -0,0 +1,9 @@
|
|||
ALTER TYPE audit_action
|
||||
ADD VALUE IF NOT EXISTS 'login';
|
||||
|
||||
ALTER TYPE audit_action
|
||||
ADD VALUE IF NOT EXISTS 'logout';
|
||||
|
||||
ALTER TYPE resource_type
|
||||
ADD VALUE IF NOT EXISTS 'api_key';
|
||||
|
|
@ -144,6 +144,8 @@ const (
|
|||
AuditActionDelete AuditAction = "delete"
|
||||
AuditActionStart AuditAction = "start"
|
||||
AuditActionStop AuditAction = "stop"
|
||||
AuditActionLogin AuditAction = "login"
|
||||
AuditActionLogout AuditAction = "logout"
|
||||
)
|
||||
|
||||
func (e *AuditAction) Scan(src interface{}) error {
|
||||
|
@ -187,7 +189,9 @@ func (e AuditAction) Valid() bool {
|
|||
AuditActionWrite,
|
||||
AuditActionDelete,
|
||||
AuditActionStart,
|
||||
AuditActionStop:
|
||||
AuditActionStop,
|
||||
AuditActionLogin,
|
||||
AuditActionLogout:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
|
@ -200,6 +204,8 @@ func AllAuditActionValues() []AuditAction {
|
|||
AuditActionDelete,
|
||||
AuditActionStart,
|
||||
AuditActionStop,
|
||||
AuditActionLogin,
|
||||
AuditActionLogout,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -95,8 +95,8 @@ func TestGitSSHKey(t *testing.T) {
|
|||
require.NotEmpty(t, key2.PublicKey)
|
||||
require.NotEqual(t, key2.PublicKey, key1.PublicKey)
|
||||
|
||||
require.Len(t, auditor.AuditLogs, 1)
|
||||
assert.Equal(t, database.AuditActionWrite, auditor.AuditLogs[0].Action)
|
||||
require.Len(t, auditor.AuditLogs, 2)
|
||||
assert.Equal(t, database.AuditActionWrite, auditor.AuditLogs[1].Action)
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
@ -61,10 +61,11 @@ func TestPostTemplateByOrganization(t *testing.T) {
|
|||
assert.Equal(t, expected.Name, got.Name)
|
||||
assert.Equal(t, expected.Description, got.Description)
|
||||
|
||||
require.Len(t, auditor.AuditLogs, 3)
|
||||
assert.Equal(t, database.AuditActionCreate, auditor.AuditLogs[0].Action)
|
||||
assert.Equal(t, database.AuditActionWrite, auditor.AuditLogs[1].Action)
|
||||
assert.Equal(t, database.AuditActionCreate, auditor.AuditLogs[2].Action)
|
||||
require.Len(t, auditor.AuditLogs, 4)
|
||||
assert.Equal(t, database.AuditActionLogin, auditor.AuditLogs[0].Action)
|
||||
assert.Equal(t, database.AuditActionCreate, auditor.AuditLogs[1].Action)
|
||||
assert.Equal(t, database.AuditActionWrite, auditor.AuditLogs[2].Action)
|
||||
assert.Equal(t, database.AuditActionCreate, auditor.AuditLogs[3].Action)
|
||||
})
|
||||
|
||||
t.Run("AlreadyExists", func(t *testing.T) {
|
||||
|
@ -285,8 +286,8 @@ func TestPatchTemplateMeta(t *testing.T) {
|
|||
assert.Equal(t, req.DefaultTTLMillis, updated.DefaultTTLMillis)
|
||||
assert.False(t, req.AllowUserCancelWorkspaceJobs)
|
||||
|
||||
require.Len(t, auditor.AuditLogs, 4)
|
||||
assert.Equal(t, database.AuditActionWrite, auditor.AuditLogs[3].Action)
|
||||
require.Len(t, auditor.AuditLogs, 5)
|
||||
assert.Equal(t, database.AuditActionWrite, auditor.AuditLogs[4].Action)
|
||||
})
|
||||
|
||||
t.Run("NoMaxTTL", func(t *testing.T) {
|
||||
|
@ -448,8 +449,8 @@ func TestDeleteTemplate(t *testing.T) {
|
|||
err := client.DeleteTemplate(ctx, template.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Len(t, auditor.AuditLogs, 4)
|
||||
assert.Equal(t, database.AuditActionDelete, auditor.AuditLogs[3].Action)
|
||||
require.Len(t, auditor.AuditLogs, 5)
|
||||
assert.Equal(t, database.AuditActionDelete, auditor.AuditLogs[4].Action)
|
||||
})
|
||||
|
||||
t.Run("Workspaces", func(t *testing.T) {
|
||||
|
|
|
@ -127,8 +127,8 @@ func TestPostTemplateVersionsByOrganization(t *testing.T) {
|
|||
require.Equal(t, "bananas", version.Name)
|
||||
require.Equal(t, provisionerdserver.ScopeOrganization, version.Job.Tags[provisionerdserver.TagScope])
|
||||
|
||||
require.Len(t, auditor.AuditLogs, 1)
|
||||
assert.Equal(t, database.AuditActionCreate, auditor.AuditLogs[0].Action)
|
||||
require.Len(t, auditor.AuditLogs, 2)
|
||||
assert.Equal(t, database.AuditActionCreate, auditor.AuditLogs[1].Action)
|
||||
})
|
||||
t.Run("Example", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
@ -646,8 +646,8 @@ func TestPatchActiveTemplateVersion(t *testing.T) {
|
|||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Len(t, auditor.AuditLogs, 4)
|
||||
assert.Equal(t, database.AuditActionWrite, auditor.AuditLogs[3].Action)
|
||||
require.Len(t, auditor.AuditLogs, 5)
|
||||
assert.Equal(t, database.AuditActionWrite, auditor.AuditLogs[4].Action)
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
@ -17,12 +17,225 @@ import (
|
|||
"golang.org/x/oauth2"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"cdr.dev/slog"
|
||||
|
||||
"github.com/coder/coder/coderd/audit"
|
||||
"github.com/coder/coder/coderd/database"
|
||||
"github.com/coder/coder/coderd/httpapi"
|
||||
"github.com/coder/coder/coderd/httpmw"
|
||||
"github.com/coder/coder/coderd/rbac"
|
||||
"github.com/coder/coder/coderd/userpassword"
|
||||
"github.com/coder/coder/codersdk"
|
||||
)
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
user, err := api.Database.GetUserByEmailOrUsername(ctx, database.GetUserByEmailOrUsernameParams{
|
||||
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.
|
||||
if api.DeploymentConfig.DisablePasswordAuth.Value {
|
||||
permitted := false
|
||||
for _, role := range user.RBACRoles {
|
||||
if role == rbac.RoleOwner() {
|
||||
permitted = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !permitted {
|
||||
httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{
|
||||
Message: "Password authentication is disabled. Only administrators can sign in with password authentication.",
|
||||
})
|
||||
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
|
||||
}
|
||||
|
||||
// If the user logged into a suspended account, reject the login request.
|
||||
if user.Status != database.UserStatusActive {
|
||||
httpapi.Write(ctx, rw, http.StatusUnauthorized, codersdk.Response{
|
||||
Message: "Your account is suspended. Contact an admin to reactivate your account.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
cookie, key, err := api.createAPIKey(ctx, createAPIKeyParams{
|
||||
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.
|
||||
if appCookie, _ := r.Cookie(httpmw.DevURLSessionTokenCookie); appCookie != nil {
|
||||
appCookieRemove := &http.Cookie{
|
||||
// MaxAge < 0 means to delete the cookie now.
|
||||
MaxAge: -1,
|
||||
Name: httpmw.DevURLSessionTokenCookie,
|
||||
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!",
|
||||
})
|
||||
}
|
||||
|
||||
// GithubOAuth2Team represents a team scoped to an organization.
|
||||
type GithubOAuth2Team struct {
|
||||
Organization string
|
||||
|
@ -82,9 +295,18 @@ func (api *API) userAuthMethods(rw http.ResponseWriter, r *http.Request) {
|
|||
// @Router /users/oauth2/github/callback [get]
|
||||
func (api *API) userOAuth2Github(rw http.ResponseWriter, r *http.Request) {
|
||||
var (
|
||||
ctx = r.Context()
|
||||
state = httpmw.OAuth2(r)
|
||||
ctx = r.Context()
|
||||
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,
|
||||
})
|
||||
)
|
||||
aReq.Old = database.APIKey{}
|
||||
defer commitAudit()
|
||||
|
||||
oauthClient := oauth2.NewClient(ctx, oauth2.StaticTokenSource(state.Token))
|
||||
|
||||
|
@ -183,7 +405,19 @@ func (api *API) userOAuth2Github(rw http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
cookie, err := api.oauthLogin(r, oauthLoginParams{
|
||||
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
|
||||
}
|
||||
aReq.UserID = user.ID
|
||||
|
||||
cookie, key, err := api.oauthLogin(r, oauthLoginParams{
|
||||
User: user,
|
||||
Link: link,
|
||||
State: state,
|
||||
LinkedID: githubLinkedID(ghUser),
|
||||
LoginType: database.LoginTypeGithub,
|
||||
|
@ -207,6 +441,7 @@ func (api *API) userOAuth2Github(rw http.ResponseWriter, r *http.Request) {
|
|||
})
|
||||
return
|
||||
}
|
||||
aReq.New = key
|
||||
|
||||
http.SetCookie(rw, cookie)
|
||||
|
||||
|
@ -245,9 +480,18 @@ type OIDCConfig struct {
|
|||
// @Router /users/oidc/callback [get]
|
||||
func (api *API) userOIDC(rw http.ResponseWriter, r *http.Request) {
|
||||
var (
|
||||
ctx = r.Context()
|
||||
state = httpmw.OAuth2(r)
|
||||
ctx = r.Context()
|
||||
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,
|
||||
})
|
||||
)
|
||||
aReq.Old = database.APIKey{}
|
||||
defer commitAudit()
|
||||
|
||||
// See the example here: https://github.com/coreos/go-oidc
|
||||
rawIDToken, ok := state.Token.Extra("id_token").(string)
|
||||
|
@ -407,7 +651,19 @@ func (api *API) userOIDC(rw http.ResponseWriter, r *http.Request) {
|
|||
picture, _ = pictureRaw.(string)
|
||||
}
|
||||
|
||||
cookie, err := api.oauthLogin(r, oauthLoginParams{
|
||||
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
|
||||
}
|
||||
aReq.UserID = user.ID
|
||||
|
||||
cookie, key, err := api.oauthLogin(r, oauthLoginParams{
|
||||
User: user,
|
||||
Link: link,
|
||||
State: state,
|
||||
LinkedID: oidcLinkedID(idToken),
|
||||
LoginType: database.LoginTypeOIDC,
|
||||
|
@ -432,6 +688,7 @@ func (api *API) userOIDC(rw http.ResponseWriter, r *http.Request) {
|
|||
})
|
||||
return
|
||||
}
|
||||
aReq.New = key
|
||||
|
||||
http.SetCookie(rw, cookie)
|
||||
|
||||
|
@ -443,6 +700,8 @@ func (api *API) userOIDC(rw http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
type oauthLoginParams struct {
|
||||
User database.User
|
||||
Link database.UserLink
|
||||
State httpmw.OAuth2State
|
||||
LinkedID string
|
||||
LoginType database.LoginType
|
||||
|
@ -470,7 +729,7 @@ func (e httpError) Error() string {
|
|||
return e.msg
|
||||
}
|
||||
|
||||
func (api *API) oauthLogin(r *http.Request, params oauthLoginParams) (*http.Cookie, error) {
|
||||
func (api *API) oauthLogin(r *http.Request, params oauthLoginParams) (*http.Cookie, database.APIKey, error) {
|
||||
var (
|
||||
ctx = r.Context()
|
||||
user database.User
|
||||
|
@ -482,10 +741,8 @@ func (api *API) oauthLogin(r *http.Request, params oauthLoginParams) (*http.Cook
|
|||
err error
|
||||
)
|
||||
|
||||
user, link, err = findLinkedUser(ctx, tx, params.LinkedID, params.Email)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("find linked user: %w", err)
|
||||
}
|
||||
user = params.User
|
||||
link = params.Link
|
||||
|
||||
if user.ID == uuid.Nil && !params.AllowSignups {
|
||||
return httpError{
|
||||
|
@ -638,19 +895,19 @@ func (api *API) oauthLogin(r *http.Request, params oauthLoginParams) (*http.Cook
|
|||
return nil
|
||||
}, nil)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("in tx: %w", err)
|
||||
return nil, database.APIKey{}, xerrors.Errorf("in tx: %w", err)
|
||||
}
|
||||
|
||||
cookie, err := api.createAPIKey(ctx, createAPIKeyParams{
|
||||
cookie, key, err := api.createAPIKey(ctx, createAPIKeyParams{
|
||||
UserID: user.ID,
|
||||
LoginType: params.LoginType,
|
||||
RemoteAddr: r.RemoteAddr,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("create API key: %w", err)
|
||||
return nil, database.APIKey{}, xerrors.Errorf("create API key: %w", err)
|
||||
}
|
||||
|
||||
return cookie, nil
|
||||
return cookie, *key, nil
|
||||
}
|
||||
|
||||
// githubLinkedID returns the unique ID for a GitHub user.
|
||||
|
|
|
@ -20,6 +20,7 @@ import (
|
|||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/coderd"
|
||||
"github.com/coder/coder/coderd/audit"
|
||||
"github.com/coder/coder/coderd/coderdtest"
|
||||
"github.com/coder/coder/coderd/database"
|
||||
"github.com/coder/coder/codersdk"
|
||||
|
@ -105,7 +106,9 @@ func TestUserOAuth2Github(t *testing.T) {
|
|||
|
||||
t.Run("NotInAllowedOrganization", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
auditor := audit.NewMock()
|
||||
client := coderdtest.New(t, &coderdtest.Options{
|
||||
Auditor: auditor,
|
||||
GithubOAuth2Config: &coderd.GithubOAuth2Config{
|
||||
OAuth2Config: &oauth2Config{},
|
||||
ListOrganizationMemberships: func(ctx context.Context, client *http.Client) ([]*github.Membership, error) {
|
||||
|
@ -118,13 +121,19 @@ func TestUserOAuth2Github(t *testing.T) {
|
|||
},
|
||||
},
|
||||
})
|
||||
numLogs := len(auditor.AuditLogs)
|
||||
|
||||
resp := oauth2Callback(t, client)
|
||||
numLogs++ // add an audit log for login
|
||||
require.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||
require.Len(t, auditor.AuditLogs, numLogs)
|
||||
require.Equal(t, database.AuditActionLogin, auditor.AuditLogs[numLogs-1].Action)
|
||||
})
|
||||
t.Run("NotInAllowedTeam", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
auditor := audit.NewMock()
|
||||
client := coderdtest.New(t, &coderdtest.Options{
|
||||
Auditor: auditor,
|
||||
GithubOAuth2Config: &coderd.GithubOAuth2Config{
|
||||
AllowOrganizations: []string{"coder"},
|
||||
AllowTeams: []coderd.GithubOAuth2Team{{"another", "something"}, {"coder", "frontend"}},
|
||||
|
@ -147,12 +156,20 @@ func TestUserOAuth2Github(t *testing.T) {
|
|||
},
|
||||
},
|
||||
})
|
||||
numLogs := len(auditor.AuditLogs)
|
||||
|
||||
resp := oauth2Callback(t, client)
|
||||
numLogs++ // add an audit log for login
|
||||
|
||||
require.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||
require.Len(t, auditor.AuditLogs, numLogs)
|
||||
require.Equal(t, database.AuditActionLogin, auditor.AuditLogs[numLogs-1].Action)
|
||||
})
|
||||
t.Run("UnverifiedEmail", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
auditor := audit.NewMock()
|
||||
client := coderdtest.New(t, &coderdtest.Options{
|
||||
Auditor: auditor,
|
||||
GithubOAuth2Config: &coderd.GithubOAuth2Config{
|
||||
OAuth2Config: &oauth2Config{},
|
||||
AllowOrganizations: []string{"coder"},
|
||||
|
@ -175,13 +192,23 @@ func TestUserOAuth2Github(t *testing.T) {
|
|||
},
|
||||
},
|
||||
})
|
||||
numLogs := len(auditor.AuditLogs)
|
||||
|
||||
_ = coderdtest.CreateFirstUser(t, client)
|
||||
numLogs++ // add an audit log for user create
|
||||
|
||||
resp := oauth2Callback(t, client)
|
||||
numLogs++ // add an audit log for login
|
||||
|
||||
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
require.Len(t, auditor.AuditLogs, numLogs)
|
||||
require.Equal(t, database.AuditActionLogin, auditor.AuditLogs[numLogs-1].Action)
|
||||
})
|
||||
t.Run("BlockSignups", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
auditor := audit.NewMock()
|
||||
client := coderdtest.New(t, &coderdtest.Options{
|
||||
Auditor: auditor,
|
||||
GithubOAuth2Config: &coderd.GithubOAuth2Config{
|
||||
OAuth2Config: &oauth2Config{},
|
||||
AllowOrganizations: []string{"coder"},
|
||||
|
@ -205,12 +232,20 @@ func TestUserOAuth2Github(t *testing.T) {
|
|||
},
|
||||
},
|
||||
})
|
||||
numLogs := len(auditor.AuditLogs)
|
||||
|
||||
resp := oauth2Callback(t, client)
|
||||
numLogs++ // add an audit log for login
|
||||
|
||||
require.Equal(t, http.StatusForbidden, resp.StatusCode)
|
||||
require.Len(t, auditor.AuditLogs, numLogs)
|
||||
require.Equal(t, database.AuditActionLogin, auditor.AuditLogs[numLogs-1].Action)
|
||||
})
|
||||
t.Run("MultiLoginNotAllowed", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
auditor := audit.NewMock()
|
||||
client := coderdtest.New(t, &coderdtest.Options{
|
||||
Auditor: auditor,
|
||||
GithubOAuth2Config: &coderd.GithubOAuth2Config{
|
||||
OAuth2Config: &oauth2Config{},
|
||||
AllowOrganizations: []string{"coder"},
|
||||
|
@ -234,16 +269,26 @@ func TestUserOAuth2Github(t *testing.T) {
|
|||
},
|
||||
},
|
||||
})
|
||||
numLogs := len(auditor.AuditLogs)
|
||||
|
||||
// Creates the first user with login_type 'password'.
|
||||
_ = coderdtest.CreateFirstUser(t, client)
|
||||
numLogs++ // add an audit log for user create
|
||||
|
||||
// Attempting to login should give us a 403 since the user
|
||||
// already has a login_type of 'password'.
|
||||
resp := oauth2Callback(t, client)
|
||||
numLogs++ // add an audit log for login
|
||||
|
||||
require.Equal(t, http.StatusForbidden, resp.StatusCode)
|
||||
require.Len(t, auditor.AuditLogs, numLogs)
|
||||
require.Equal(t, database.AuditActionLogin, auditor.AuditLogs[numLogs-1].Action)
|
||||
})
|
||||
t.Run("Signup", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
auditor := audit.NewMock()
|
||||
client := coderdtest.New(t, &coderdtest.Options{
|
||||
Auditor: auditor,
|
||||
GithubOAuth2Config: &coderd.GithubOAuth2Config{
|
||||
OAuth2Config: &oauth2Config{},
|
||||
AllowOrganizations: []string{"coder"},
|
||||
|
@ -272,7 +317,11 @@ func TestUserOAuth2Github(t *testing.T) {
|
|||
},
|
||||
},
|
||||
})
|
||||
numLogs := len(auditor.AuditLogs)
|
||||
|
||||
resp := oauth2Callback(t, client)
|
||||
numLogs++ // add an audit log for login
|
||||
|
||||
require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode)
|
||||
|
||||
client.SetSessionToken(authCookieValue(resp.Cookies()))
|
||||
|
@ -281,10 +330,15 @@ func TestUserOAuth2Github(t *testing.T) {
|
|||
require.Equal(t, "kyle@coder.com", user.Email)
|
||||
require.Equal(t, "kyle", user.Username)
|
||||
require.Equal(t, "/hello-world", user.AvatarURL)
|
||||
|
||||
require.Len(t, auditor.AuditLogs, numLogs)
|
||||
require.Equal(t, database.AuditActionLogin, auditor.AuditLogs[numLogs-1].Action)
|
||||
})
|
||||
t.Run("SignupAllowedTeam", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
auditor := audit.NewMock()
|
||||
client := coderdtest.New(t, &coderdtest.Options{
|
||||
Auditor: auditor,
|
||||
GithubOAuth2Config: &coderd.GithubOAuth2Config{
|
||||
AllowSignups: true,
|
||||
AllowOrganizations: []string{"coder"},
|
||||
|
@ -315,12 +369,20 @@ func TestUserOAuth2Github(t *testing.T) {
|
|||
},
|
||||
},
|
||||
})
|
||||
numLogs := len(auditor.AuditLogs)
|
||||
|
||||
resp := oauth2Callback(t, client)
|
||||
numLogs++ // add an audit log for login
|
||||
|
||||
require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode)
|
||||
require.Len(t, auditor.AuditLogs, numLogs)
|
||||
require.Equal(t, database.AuditActionLogin, auditor.AuditLogs[numLogs-1].Action)
|
||||
})
|
||||
t.Run("SignupAllowedTeamInFirstOrganization", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
auditor := audit.NewMock()
|
||||
client := coderdtest.New(t, &coderdtest.Options{
|
||||
Auditor: auditor,
|
||||
GithubOAuth2Config: &coderd.GithubOAuth2Config{
|
||||
AllowSignups: true,
|
||||
AllowOrganizations: []string{"coder", "nil"},
|
||||
|
@ -359,12 +421,20 @@ func TestUserOAuth2Github(t *testing.T) {
|
|||
},
|
||||
},
|
||||
})
|
||||
numLogs := len(auditor.AuditLogs)
|
||||
|
||||
resp := oauth2Callback(t, client)
|
||||
numLogs++ // add an audit log for login
|
||||
|
||||
require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode)
|
||||
require.Len(t, auditor.AuditLogs, numLogs)
|
||||
require.Equal(t, database.AuditActionLogin, auditor.AuditLogs[numLogs-1].Action)
|
||||
})
|
||||
t.Run("SignupAllowedTeamInSecondOrganization", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
auditor := audit.NewMock()
|
||||
client := coderdtest.New(t, &coderdtest.Options{
|
||||
Auditor: auditor,
|
||||
GithubOAuth2Config: &coderd.GithubOAuth2Config{
|
||||
AllowSignups: true,
|
||||
AllowOrganizations: []string{"coder", "nil"},
|
||||
|
@ -403,12 +473,20 @@ func TestUserOAuth2Github(t *testing.T) {
|
|||
},
|
||||
},
|
||||
})
|
||||
numLogs := len(auditor.AuditLogs)
|
||||
|
||||
resp := oauth2Callback(t, client)
|
||||
numLogs++ // add an audit log for login
|
||||
|
||||
require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode)
|
||||
require.Len(t, auditor.AuditLogs, numLogs)
|
||||
require.Equal(t, database.AuditActionLogin, auditor.AuditLogs[numLogs-1].Action)
|
||||
})
|
||||
t.Run("SignupAllowEveryone", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
auditor := audit.NewMock()
|
||||
client := coderdtest.New(t, &coderdtest.Options{
|
||||
Auditor: auditor,
|
||||
GithubOAuth2Config: &coderd.GithubOAuth2Config{
|
||||
AllowSignups: true,
|
||||
AllowEveryone: true,
|
||||
|
@ -433,12 +511,20 @@ func TestUserOAuth2Github(t *testing.T) {
|
|||
},
|
||||
},
|
||||
})
|
||||
numLogs := len(auditor.AuditLogs)
|
||||
|
||||
resp := oauth2Callback(t, client)
|
||||
numLogs++ // add an audit log for login
|
||||
|
||||
require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode)
|
||||
require.Len(t, auditor.AuditLogs, numLogs)
|
||||
require.Equal(t, database.AuditActionLogin, auditor.AuditLogs[numLogs-1].Action)
|
||||
})
|
||||
t.Run("SignupFailedInactiveInOrg", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
auditor := audit.NewMock()
|
||||
client := coderdtest.New(t, &coderdtest.Options{
|
||||
Auditor: auditor,
|
||||
GithubOAuth2Config: &coderd.GithubOAuth2Config{
|
||||
AllowSignups: true,
|
||||
AllowOrganizations: []string{"coder"},
|
||||
|
@ -469,8 +555,14 @@ func TestUserOAuth2Github(t *testing.T) {
|
|||
},
|
||||
},
|
||||
})
|
||||
numLogs := len(auditor.AuditLogs)
|
||||
|
||||
resp := oauth2Callback(t, client)
|
||||
numLogs++ // add an audit log for login
|
||||
|
||||
require.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||
require.Len(t, auditor.AuditLogs, numLogs)
|
||||
require.Equal(t, database.AuditActionLogin, auditor.AuditLogs[numLogs-1].Action)
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -634,6 +726,7 @@ func TestUserOIDC(t *testing.T) {
|
|||
tc := tc
|
||||
t.Run(tc.Name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
auditor := audit.NewMock()
|
||||
conf := coderdtest.NewOIDCConfig(t, "")
|
||||
|
||||
config := conf.OIDCConfig(t, tc.UserInfoClaims)
|
||||
|
@ -642,9 +735,13 @@ func TestUserOIDC(t *testing.T) {
|
|||
config.IgnoreEmailVerified = tc.IgnoreEmailVerified
|
||||
|
||||
client := coderdtest.New(t, &coderdtest.Options{
|
||||
Auditor: auditor,
|
||||
OIDCConfig: config,
|
||||
})
|
||||
numLogs := len(auditor.AuditLogs)
|
||||
|
||||
resp := oidcCallback(t, client, conf.EncodeClaims(t, tc.IDTokenClaims))
|
||||
numLogs++ // add an audit log for login
|
||||
assert.Equal(t, tc.StatusCode, resp.StatusCode)
|
||||
|
||||
ctx, _ := testutil.Context(t)
|
||||
|
@ -654,6 +751,9 @@ func TestUserOIDC(t *testing.T) {
|
|||
user, err := client.User(ctx, "me")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tc.Username, user.Username)
|
||||
|
||||
require.Len(t, auditor.AuditLogs, numLogs)
|
||||
require.Equal(t, database.AuditActionLogin, auditor.AuditLogs[numLogs-1].Action)
|
||||
}
|
||||
|
||||
if tc.AvatarURL != "" {
|
||||
|
@ -661,26 +761,33 @@ func TestUserOIDC(t *testing.T) {
|
|||
user, err := client.User(ctx, "me")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tc.AvatarURL, user.AvatarURL)
|
||||
|
||||
require.Len(t, auditor.AuditLogs, numLogs)
|
||||
require.Equal(t, database.AuditActionLogin, auditor.AuditLogs[numLogs-1].Action)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("AlternateUsername", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
auditor := audit.NewMock()
|
||||
conf := coderdtest.NewOIDCConfig(t, "")
|
||||
|
||||
config := conf.OIDCConfig(t, nil)
|
||||
config.AllowSignups = true
|
||||
|
||||
client := coderdtest.New(t, &coderdtest.Options{
|
||||
Auditor: auditor,
|
||||
OIDCConfig: config,
|
||||
})
|
||||
numLogs := len(auditor.AuditLogs)
|
||||
|
||||
code := conf.EncodeClaims(t, jwt.MapClaims{
|
||||
"email": "jon@coder.com",
|
||||
})
|
||||
resp := oidcCallback(t, client, code)
|
||||
numLogs++ // add an audit log for login
|
||||
|
||||
assert.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode)
|
||||
|
||||
ctx, _ := testutil.Context(t)
|
||||
|
@ -697,12 +804,17 @@ func TestUserOIDC(t *testing.T) {
|
|||
"sub": "diff",
|
||||
})
|
||||
resp = oidcCallback(t, client, code)
|
||||
numLogs++ // add an audit log for login
|
||||
|
||||
assert.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode)
|
||||
|
||||
client.SetSessionToken(authCookieValue(resp.Cookies()))
|
||||
user, err = client.User(ctx, "me")
|
||||
require.NoError(t, err)
|
||||
require.True(t, strings.HasPrefix(user.Username, "jon-"), "username %q should have prefix %q", user.Username, "jon-")
|
||||
|
||||
require.Len(t, auditor.AuditLogs, numLogs)
|
||||
require.Equal(t, database.AuditActionLogin, auditor.AuditLogs[numLogs-1].Action)
|
||||
})
|
||||
|
||||
t.Run("Disabled", func(t *testing.T) {
|
||||
|
@ -714,23 +826,33 @@ func TestUserOIDC(t *testing.T) {
|
|||
|
||||
t.Run("NoIDToken", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
auditor := audit.NewMock()
|
||||
client := coderdtest.New(t, &coderdtest.Options{
|
||||
Auditor: auditor,
|
||||
OIDCConfig: &coderd.OIDCConfig{
|
||||
OAuth2Config: &oauth2Config{},
|
||||
},
|
||||
})
|
||||
numLogs := len(auditor.AuditLogs)
|
||||
|
||||
resp := oidcCallback(t, client, "asdf")
|
||||
numLogs++ // add an audit log for login
|
||||
|
||||
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
require.Len(t, auditor.AuditLogs, numLogs)
|
||||
require.Equal(t, database.AuditActionLogin, auditor.AuditLogs[numLogs-1].Action)
|
||||
})
|
||||
|
||||
t.Run("BadVerify", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
auditor := audit.NewMock()
|
||||
verifier := oidc.NewVerifier("", &oidc.StaticKeySet{
|
||||
PublicKeys: []crypto.PublicKey{},
|
||||
}, &oidc.Config{})
|
||||
provider := &oidc.Provider{}
|
||||
|
||||
client := coderdtest.New(t, &coderdtest.Options{
|
||||
Auditor: auditor,
|
||||
OIDCConfig: &coderd.OIDCConfig{
|
||||
OAuth2Config: &oauth2Config{
|
||||
token: (&oauth2.Token{
|
||||
|
@ -743,8 +865,14 @@ func TestUserOIDC(t *testing.T) {
|
|||
Verifier: verifier,
|
||||
},
|
||||
})
|
||||
numLogs := len(auditor.AuditLogs)
|
||||
|
||||
resp := oidcCallback(t, client, "asdf")
|
||||
numLogs++ // add an audit log for login
|
||||
|
||||
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
require.Len(t, auditor.AuditLogs, numLogs)
|
||||
require.Equal(t, database.AuditActionLogin, auditor.AuditLogs[numLogs-1].Action)
|
||||
})
|
||||
}
|
||||
|
||||
|
|
178
coderd/users.go
178
coderd/users.go
|
@ -14,7 +14,6 @@ import (
|
|||
"github.com/google/uuid"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"cdr.dev/slog"
|
||||
"github.com/coder/coder/coderd/audit"
|
||||
"github.com/coder/coder/coderd/database"
|
||||
"github.com/coder/coder/coderd/gitsshkey"
|
||||
|
@ -984,183 +983,6 @@ func (api *API) organizationByUserAndName(rw http.ResponseWriter, r *http.Reques
|
|||
httpapi.Write(ctx, rw, http.StatusOK, convertOrganization(organization))
|
||||
}
|
||||
|
||||
// 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) {
|
||||
ctx := r.Context()
|
||||
var loginWithPassword codersdk.LoginWithPasswordRequest
|
||||
if !httpapi.Read(ctx, rw, r, &loginWithPassword) {
|
||||
return
|
||||
}
|
||||
|
||||
user, err := api.Database.GetUserByEmailOrUsername(ctx, database.GetUserByEmailOrUsernameParams{
|
||||
Email: loginWithPassword.Email,
|
||||
})
|
||||
if err != nil && !xerrors.Is(err, sql.ErrNoRows) {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Internal error.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 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.
|
||||
if api.DeploymentConfig.DisablePasswordAuth.Value {
|
||||
permitted := false
|
||||
for _, role := range user.RBACRoles {
|
||||
if role == rbac.RoleOwner() {
|
||||
permitted = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !permitted {
|
||||
httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{
|
||||
Message: "Password authentication is disabled. Only administrators can sign in with password authentication.",
|
||||
})
|
||||
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
|
||||
}
|
||||
|
||||
// If the user logged into a suspended account, reject the login request.
|
||||
if user.Status != database.UserStatusActive {
|
||||
httpapi.Write(ctx, rw, http.StatusUnauthorized, codersdk.Response{
|
||||
Message: "Your account is suspended. Contact an admin to reactivate your account.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
cookie, err := api.createAPIKey(ctx, createAPIKeyParams{
|
||||
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
|
||||
}
|
||||
|
||||
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) {
|
||||
ctx := r.Context()
|
||||
// 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)
|
||||
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.
|
||||
if appCookie, _ := r.Cookie(httpmw.DevURLSessionTokenCookie); appCookie != nil {
|
||||
appCookieRemove := &http.Cookie{
|
||||
// MaxAge < 0 means to delete the cookie now.
|
||||
MaxAge: -1,
|
||||
Name: httpmw.DevURLSessionTokenCookie,
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
httpapi.Write(ctx, rw, http.StatusOK, codersdk.Response{
|
||||
Message: "Logged out!",
|
||||
})
|
||||
}
|
||||
|
||||
type CreateUserRequest struct {
|
||||
codersdk.CreateUserRequest
|
||||
LoginType database.LoginType
|
||||
|
|
|
@ -10,7 +10,6 @@ import (
|
|||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/sync/errgroup"
|
||||
|
||||
|
@ -92,7 +91,9 @@ func TestPostLogin(t *testing.T) {
|
|||
t.Parallel()
|
||||
t.Run("InvalidUser", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, nil)
|
||||
auditor := audit.NewMock()
|
||||
client := coderdtest.New(t, &coderdtest.Options{Auditor: auditor})
|
||||
numLogs := len(auditor.AuditLogs)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
@ -101,14 +102,20 @@ func TestPostLogin(t *testing.T) {
|
|||
Email: "my@email.org",
|
||||
Password: "password",
|
||||
})
|
||||
numLogs++ // add an audit log for login
|
||||
var apiErr *codersdk.Error
|
||||
require.ErrorAs(t, err, &apiErr)
|
||||
require.Equal(t, http.StatusUnauthorized, apiErr.StatusCode())
|
||||
|
||||
require.Len(t, auditor.AuditLogs, numLogs)
|
||||
require.Equal(t, database.AuditActionLogin, auditor.AuditLogs[numLogs-1].Action)
|
||||
})
|
||||
|
||||
t.Run("BadPassword", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, nil)
|
||||
auditor := audit.NewMock()
|
||||
client := coderdtest.New(t, &coderdtest.Options{Auditor: auditor})
|
||||
numLogs := len(auditor.AuditLogs)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
@ -124,17 +131,26 @@ func TestPostLogin(t *testing.T) {
|
|||
Email: req.Email,
|
||||
Password: "badpass",
|
||||
})
|
||||
numLogs++ // add an audit log for login
|
||||
var apiErr *codersdk.Error
|
||||
require.ErrorAs(t, err, &apiErr)
|
||||
require.Equal(t, http.StatusUnauthorized, apiErr.StatusCode())
|
||||
|
||||
require.Len(t, auditor.AuditLogs, numLogs)
|
||||
require.Equal(t, database.AuditActionLogin, auditor.AuditLogs[numLogs-1].Action)
|
||||
})
|
||||
|
||||
t.Run("Suspended", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, nil)
|
||||
auditor := audit.NewMock()
|
||||
client := coderdtest.New(t, &coderdtest.Options{Auditor: auditor})
|
||||
numLogs := len(auditor.AuditLogs)
|
||||
first := coderdtest.CreateFirstUser(t, client)
|
||||
numLogs++ // add an audit log for create user
|
||||
numLogs++ // add an audit log for login
|
||||
|
||||
member := coderdtest.CreateAnotherUser(t, client, first.OrganizationID)
|
||||
numLogs++ // add an audit log for create user
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
@ -144,6 +160,7 @@ func TestPostLogin(t *testing.T) {
|
|||
|
||||
_, err = client.UpdateUserStatus(ctx, memberUser.Username, codersdk.UserStatusSuspended)
|
||||
require.NoError(t, err, "suspend member")
|
||||
numLogs++ // add an audit log for update user
|
||||
|
||||
// Test an existing session
|
||||
_, err = member.User(ctx, codersdk.Me)
|
||||
|
@ -157,9 +174,13 @@ func TestPostLogin(t *testing.T) {
|
|||
Email: memberUser.Email,
|
||||
Password: "testpass",
|
||||
})
|
||||
numLogs++ // add an audit log for login
|
||||
require.ErrorAs(t, err, &apiErr)
|
||||
require.Equal(t, http.StatusUnauthorized, apiErr.StatusCode())
|
||||
require.Contains(t, apiErr.Message, "suspended")
|
||||
|
||||
require.Len(t, auditor.AuditLogs, numLogs)
|
||||
require.Equal(t, database.AuditActionLogin, auditor.AuditLogs[numLogs-1].Action)
|
||||
})
|
||||
|
||||
t.Run("DisabledPasswordAuth", func(t *testing.T) {
|
||||
|
@ -214,7 +235,9 @@ func TestPostLogin(t *testing.T) {
|
|||
|
||||
t.Run("Success", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, nil)
|
||||
auditor := audit.NewMock()
|
||||
client := coderdtest.New(t, &coderdtest.Options{Auditor: auditor})
|
||||
numLogs := len(auditor.AuditLogs)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
@ -226,6 +249,8 @@ func TestPostLogin(t *testing.T) {
|
|||
}
|
||||
_, err := client.CreateFirstUser(ctx, req)
|
||||
require.NoError(t, err)
|
||||
numLogs++ // add an audit log for create user
|
||||
numLogs++ // add an audit log for login
|
||||
|
||||
_, err = client.LoginWithPassword(ctx, codersdk.LoginWithPasswordRequest{
|
||||
Email: req.Email,
|
||||
|
@ -239,6 +264,9 @@ func TestPostLogin(t *testing.T) {
|
|||
Password: req.Password,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Len(t, auditor.AuditLogs, numLogs)
|
||||
require.Equal(t, database.AuditActionLogin, auditor.AuditLogs[numLogs-1].Action)
|
||||
})
|
||||
|
||||
t.Run("Lifetime&Expire", func(t *testing.T) {
|
||||
|
@ -320,9 +348,12 @@ func TestPostLogout(t *testing.T) {
|
|||
// Checks that the cookie is cleared and the API Key is deleted from the database.
|
||||
t.Run("Logout", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
auditor := audit.NewMock()
|
||||
client := coderdtest.New(t, &coderdtest.Options{Auditor: auditor})
|
||||
numLogs := len(auditor.AuditLogs)
|
||||
|
||||
client := coderdtest.New(t, nil)
|
||||
admin := coderdtest.CreateFirstUser(t, client)
|
||||
numLogs++ // add an audit log for login
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
@ -336,10 +367,15 @@ func TestPostLogout(t *testing.T) {
|
|||
require.NoError(t, err, "Server URL should parse successfully")
|
||||
|
||||
res, err := client.Request(ctx, http.MethodPost, fullURL.String(), nil)
|
||||
numLogs++ // add an audit log for logout
|
||||
|
||||
require.NoError(t, err, "/logout request should succeed")
|
||||
res.Body.Close()
|
||||
require.Equal(t, http.StatusOK, res.StatusCode)
|
||||
|
||||
require.Len(t, auditor.AuditLogs, numLogs)
|
||||
require.Equal(t, database.AuditActionLogout, auditor.AuditLogs[numLogs-1].Action)
|
||||
|
||||
cookies := res.Cookies()
|
||||
|
||||
var found bool
|
||||
|
@ -442,7 +478,11 @@ func TestPostUsers(t *testing.T) {
|
|||
t.Parallel()
|
||||
auditor := audit.NewMock()
|
||||
client := coderdtest.New(t, &coderdtest.Options{Auditor: auditor})
|
||||
numLogs := len(auditor.AuditLogs)
|
||||
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
numLogs++ // add an audit log for user create
|
||||
numLogs++ // add an audit log for login
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
@ -455,8 +495,9 @@ func TestPostUsers(t *testing.T) {
|
|||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Len(t, auditor.AuditLogs, 1)
|
||||
assert.Equal(t, database.AuditActionCreate, auditor.AuditLogs[0].Action)
|
||||
require.Len(t, auditor.AuditLogs, numLogs)
|
||||
require.Equal(t, database.AuditActionCreate, auditor.AuditLogs[numLogs-1].Action)
|
||||
require.Equal(t, database.AuditActionLogin, auditor.AuditLogs[numLogs-2].Action)
|
||||
})
|
||||
|
||||
t.Run("LastSeenAt", func(t *testing.T) {
|
||||
|
@ -536,7 +577,10 @@ func TestUpdateUserProfile(t *testing.T) {
|
|||
t.Parallel()
|
||||
auditor := audit.NewMock()
|
||||
client := coderdtest.New(t, &coderdtest.Options{Auditor: auditor})
|
||||
numLogs := len(auditor.AuditLogs)
|
||||
|
||||
coderdtest.CreateFirstUser(t, client)
|
||||
numLogs++ // add an audit log for login
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
@ -547,8 +591,10 @@ func TestUpdateUserProfile(t *testing.T) {
|
|||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, userProfile.Username, "newusername")
|
||||
assert.Len(t, auditor.AuditLogs, 1)
|
||||
assert.Equal(t, database.AuditActionWrite, auditor.AuditLogs[0].Action)
|
||||
numLogs++ // add an audit log for user update
|
||||
|
||||
require.Len(t, auditor.AuditLogs, numLogs)
|
||||
require.Equal(t, database.AuditActionWrite, auditor.AuditLogs[numLogs-1].Action)
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -600,8 +646,14 @@ func TestUpdateUserPassword(t *testing.T) {
|
|||
t.Parallel()
|
||||
auditor := audit.NewMock()
|
||||
client := coderdtest.New(t, &coderdtest.Options{Auditor: auditor})
|
||||
numLogs := len(auditor.AuditLogs)
|
||||
|
||||
admin := coderdtest.CreateFirstUser(t, client)
|
||||
numLogs++ // add an audit log for user create
|
||||
numLogs++ // add an audit log for login
|
||||
|
||||
member := coderdtest.CreateAnotherUser(t, client, admin.OrganizationID)
|
||||
numLogs++ // add an audit log for user create
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
@ -610,9 +662,12 @@ func TestUpdateUserPassword(t *testing.T) {
|
|||
OldPassword: "testpass",
|
||||
Password: "newpassword",
|
||||
})
|
||||
numLogs++ // add an audit log for user update
|
||||
|
||||
require.NoError(t, err, "member should be able to update own password")
|
||||
assert.Len(t, auditor.AuditLogs, 2)
|
||||
assert.Equal(t, database.AuditActionWrite, auditor.AuditLogs[1].Action)
|
||||
|
||||
require.Len(t, auditor.AuditLogs, numLogs)
|
||||
require.Equal(t, database.AuditActionWrite, auditor.AuditLogs[numLogs-1].Action)
|
||||
})
|
||||
t.Run("MemberCantUpdateOwnPasswordWithoutOldPassword", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
@ -632,7 +687,10 @@ func TestUpdateUserPassword(t *testing.T) {
|
|||
t.Parallel()
|
||||
auditor := audit.NewMock()
|
||||
client := coderdtest.New(t, &coderdtest.Options{Auditor: auditor})
|
||||
numLogs := len(auditor.AuditLogs)
|
||||
|
||||
_ = coderdtest.CreateFirstUser(t, client)
|
||||
numLogs++ // add an audit log for login
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
@ -640,9 +698,12 @@ func TestUpdateUserPassword(t *testing.T) {
|
|||
err := client.UpdateUserPassword(ctx, "me", codersdk.UpdateUserPasswordRequest{
|
||||
Password: "newpassword",
|
||||
})
|
||||
numLogs++ // add an audit log for user update
|
||||
|
||||
require.NoError(t, err, "admin should be able to update own password without providing old password")
|
||||
assert.Len(t, auditor.AuditLogs, 1)
|
||||
assert.Equal(t, database.AuditActionWrite, auditor.AuditLogs[0].Action)
|
||||
|
||||
require.Len(t, auditor.AuditLogs, numLogs)
|
||||
require.Equal(t, database.AuditActionWrite, auditor.AuditLogs[numLogs-1].Action)
|
||||
})
|
||||
|
||||
t.Run("ChangingPasswordDeletesKeys", func(t *testing.T) {
|
||||
|
@ -914,8 +975,14 @@ func TestPutUserSuspend(t *testing.T) {
|
|||
t.Parallel()
|
||||
auditor := audit.NewMock()
|
||||
client := coderdtest.New(t, &coderdtest.Options{Auditor: auditor})
|
||||
numLogs := len(auditor.AuditLogs)
|
||||
|
||||
me := coderdtest.CreateFirstUser(t, client)
|
||||
numLogs++ // add an audit log for user create
|
||||
numLogs++ // add an audit log for login
|
||||
|
||||
_, user := coderdtest.CreateAnotherUserWithUser(t, client, me.OrganizationID)
|
||||
numLogs++ // add an audit log for user create
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
@ -923,8 +990,10 @@ func TestPutUserSuspend(t *testing.T) {
|
|||
user, err := client.UpdateUserStatus(ctx, user.Username, codersdk.UserStatusSuspended)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, user.Status, codersdk.UserStatusSuspended)
|
||||
assert.Len(t, auditor.AuditLogs, 2)
|
||||
assert.Equal(t, database.AuditActionWrite, auditor.AuditLogs[1].Action)
|
||||
numLogs++ // add an audit log for user update
|
||||
|
||||
require.Len(t, auditor.AuditLogs, numLogs)
|
||||
require.Equal(t, database.AuditActionWrite, auditor.AuditLogs[numLogs-1].Action)
|
||||
})
|
||||
|
||||
t.Run("SuspendItSelf", func(t *testing.T) {
|
||||
|
|
|
@ -740,7 +740,7 @@ func (api *API) workspaceApplicationAuth(rw http.ResponseWriter, r *http.Request
|
|||
exp = database.Now().Add(api.DeploymentConfig.SessionDuration.Value)
|
||||
lifetimeSeconds = int64(api.DeploymentConfig.SessionDuration.Value.Seconds())
|
||||
}
|
||||
cookie, err := api.createAPIKey(ctx, createAPIKeyParams{
|
||||
cookie, _, err := api.createAPIKey(ctx, createAPIKeyParams{
|
||||
UserID: apiKey.UserID,
|
||||
LoginType: database.LoginTypePassword,
|
||||
ExpiresAt: exp,
|
||||
|
|
|
@ -578,6 +578,7 @@ func TestWorkspaceBuildStatus(t *testing.T) {
|
|||
numLogs := len(auditor.AuditLogs)
|
||||
client, closeDaemon, api := coderdtest.NewWithAPI(t, &coderdtest.Options{IncludeProvisionerDaemon: true, Auditor: auditor})
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
numLogs++ // add an audit log for login
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
|
||||
numLogs++ // add an audit log for template version creation
|
||||
numLogs++ // add an audit log for template version update
|
||||
|
|
|
@ -270,8 +270,8 @@ func TestPostWorkspacesByOrganization(t *testing.T) {
|
|||
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
|
||||
_ = coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
|
||||
|
||||
require.Len(t, auditor.AuditLogs, 4)
|
||||
assert.Equal(t, database.AuditActionCreate, auditor.AuditLogs[3].Action)
|
||||
require.Len(t, auditor.AuditLogs, 5)
|
||||
assert.Equal(t, database.AuditActionCreate, auditor.AuditLogs[4].Action)
|
||||
})
|
||||
|
||||
t.Run("CreateWithDeletedTemplate", func(t *testing.T) {
|
||||
|
@ -1283,8 +1283,8 @@ func TestWorkspaceUpdateAutostart(t *testing.T) {
|
|||
interval := next.Sub(testCase.at)
|
||||
require.Equal(t, testCase.expectedInterval, interval, "unexpected interval")
|
||||
|
||||
require.Len(t, auditor.AuditLogs, 6)
|
||||
assert.Equal(t, database.AuditActionWrite, auditor.AuditLogs[5].Action)
|
||||
require.Len(t, auditor.AuditLogs, 7)
|
||||
assert.Equal(t, database.AuditActionWrite, auditor.AuditLogs[6].Action)
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -1398,8 +1398,8 @@ func TestWorkspaceUpdateTTL(t *testing.T) {
|
|||
|
||||
require.Equal(t, testCase.ttlMillis, updated.TTLMillis, "expected autostop ttl to equal requested")
|
||||
|
||||
require.Len(t, auditor.AuditLogs, 6)
|
||||
assert.Equal(t, database.AuditActionWrite, auditor.AuditLogs[5].Action)
|
||||
require.Len(t, auditor.AuditLogs, 7)
|
||||
assert.Equal(t, database.AuditActionWrite, auditor.AuditLogs[6].Action)
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
@ -57,6 +57,8 @@ const (
|
|||
AuditActionDelete AuditAction = "delete"
|
||||
AuditActionStart AuditAction = "start"
|
||||
AuditActionStop AuditAction = "stop"
|
||||
AuditActionLogin AuditAction = "login"
|
||||
AuditActionLogout AuditAction = "logout"
|
||||
)
|
||||
|
||||
func (a AuditAction) Friendly() string {
|
||||
|
@ -71,6 +73,10 @@ func (a AuditAction) Friendly() string {
|
|||
return "started"
|
||||
case AuditActionStop:
|
||||
return "stopped"
|
||||
case AuditActionLogin:
|
||||
return "logged in"
|
||||
case AuditActionLogout:
|
||||
return "logged out"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
|
|
|
@ -11,6 +11,7 @@ We track the following resources:
|
|||
|
||||
| <b>Resource<b> | |
|
||||
| ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| APIKey<br><i>write</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>created_at</td><td>false</td></tr><tr><td>expires_at</td><td>false</td></tr><tr><td>hashed_secret</td><td>false</td></tr><tr><td>id</td><td>false</td></tr><tr><td>ip_address</td><td>false</td></tr><tr><td>last_used</td><td>false</td></tr><tr><td>lifetime_seconds</td><td>false</td></tr><tr><td>login_type</td><td>false</td></tr><tr><td>scope</td><td>false</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>user_id</td><td>false</td></tr></tbody></table> |
|
||||
| Group<br><i>create, write, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>avatar_url</td><td>true</td></tr><tr><td>id</td><td>true</td></tr><tr><td>members</td><td>true</td></tr><tr><td>name</td><td>true</td></tr><tr><td>organization_id</td><td>false</td></tr><tr><td>quota_allowance</td><td>true</td></tr></tbody></table> |
|
||||
| GitSSHKey<br><i>create</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>created_at</td><td>false</td></tr><tr><td>private_key</td><td>true</td></tr><tr><td>public_key</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>user_id</td><td>true</td></tr></tbody></table> |
|
||||
| Template<br><i>write, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>active_version_id</td><td>true</td></tr><tr><td>allow_user_cancel_workspace_jobs</td><td>true</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>created_by</td><td>true</td></tr><tr><td>default_ttl</td><td>true</td></tr><tr><td>deleted</td><td>false</td></tr><tr><td>description</td><td>true</td></tr><tr><td>display_name</td><td>true</td></tr><tr><td>group_acl</td><td>true</td></tr><tr><td>icon</td><td>true</td></tr><tr><td>id</td><td>true</td></tr><tr><td>is_private</td><td>true</td></tr><tr><td>min_autostart_interval</td><td>true</td></tr><tr><td>name</td><td>true</td></tr><tr><td>organization_id</td><td>false</td></tr><tr><td>provisioner</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>user_acl</td><td>true</td></tr></tbody></table> |
|
||||
|
|
|
@ -484,6 +484,8 @@
|
|||
| `delete` |
|
||||
| `start` |
|
||||
| `stop` |
|
||||
| `login` |
|
||||
| `logout` |
|
||||
|
||||
## codersdk.AuditDiff
|
||||
|
||||
|
|
|
@ -22,6 +22,7 @@ var AuditActionMap = map[string][]codersdk.AuditAction{
|
|||
"Workspace": {codersdk.AuditActionCreate, codersdk.AuditActionWrite, codersdk.AuditActionDelete},
|
||||
"WorkspaceBuild": {codersdk.AuditActionStart, codersdk.AuditActionStop},
|
||||
"Group": {codersdk.AuditActionCreate, codersdk.AuditActionWrite, codersdk.AuditActionDelete},
|
||||
"APIKey": {codersdk.AuditActionWrite},
|
||||
}
|
||||
|
||||
type Action string
|
||||
|
@ -109,7 +110,6 @@ var AuditableResources = auditMap(map[any]map[string]Action{
|
|||
"ttl": ActionTrack,
|
||||
"last_used_at": ActionIgnore,
|
||||
},
|
||||
// We don't show any diff for the WorkspaceBuild resource
|
||||
&database.WorkspaceBuild{}: {
|
||||
"id": ActionIgnore,
|
||||
"created_at": ActionIgnore,
|
||||
|
@ -133,6 +133,20 @@ var AuditableResources = auditMap(map[any]map[string]Action{
|
|||
"quota_allowance": ActionTrack,
|
||||
"members": ActionTrack,
|
||||
},
|
||||
// We don't show any diff for the APIKey resource
|
||||
&database.APIKey{}: {
|
||||
"id": ActionIgnore,
|
||||
"hashed_secret": ActionIgnore,
|
||||
"user_id": ActionIgnore,
|
||||
"last_used": ActionIgnore,
|
||||
"expires_at": ActionIgnore,
|
||||
"created_at": ActionIgnore,
|
||||
"updated_at": ActionIgnore,
|
||||
"login_type": ActionIgnore,
|
||||
"lifetime_seconds": ActionIgnore,
|
||||
"ip_address": ActionIgnore,
|
||||
"scope": ActionIgnore,
|
||||
},
|
||||
})
|
||||
|
||||
// auditMap converts a map of struct pointers to a map of struct names as
|
||||
|
|
|
@ -1047,10 +1047,19 @@ export type APIKeyScope = "all" | "application_connect"
|
|||
export const APIKeyScopes: APIKeyScope[] = ["all", "application_connect"]
|
||||
|
||||
// From codersdk/audit.go
|
||||
export type AuditAction = "create" | "delete" | "start" | "stop" | "write"
|
||||
export type AuditAction =
|
||||
| "create"
|
||||
| "delete"
|
||||
| "login"
|
||||
| "logout"
|
||||
| "start"
|
||||
| "stop"
|
||||
| "write"
|
||||
export const AuditActions: AuditAction[] = [
|
||||
"create",
|
||||
"delete",
|
||||
"login",
|
||||
"logout",
|
||||
"start",
|
||||
"stop",
|
||||
"write",
|
||||
|
|
|
@ -2,8 +2,12 @@ import {
|
|||
MockAuditLog,
|
||||
MockAuditLogWithWorkspaceBuild,
|
||||
MockWorkspaceCreateAuditLogForDifferentOwner,
|
||||
MockAuditLogSuccessfulLogin,
|
||||
MockAuditLogUnsuccessfulLoginKnownUser,
|
||||
MockAuditLogUnsuccessfulLoginUnknownUser,
|
||||
} from "testHelpers/entities"
|
||||
import { AuditLogDescription } from "./AuditLogDescription"
|
||||
import { AuditLogRow } from "./AuditLogRow"
|
||||
import { render } from "../../testHelpers/renderHelpers"
|
||||
import { screen } from "@testing-library/react"
|
||||
|
||||
|
@ -59,4 +63,22 @@ describe("AuditLogDescription", () => {
|
|||
),
|
||||
).toBeDefined()
|
||||
})
|
||||
it("renders the correct string for successful login", async () => {
|
||||
render(<AuditLogRow auditLog={MockAuditLogSuccessfulLogin} />)
|
||||
expect(getByTextContent(`TestUser logged in`)).toBeDefined()
|
||||
const statusPill = screen.getByRole("status")
|
||||
expect(statusPill).toHaveTextContent("201")
|
||||
})
|
||||
it("renders the correct string for unsuccessful login for a known user", async () => {
|
||||
render(<AuditLogRow auditLog={MockAuditLogUnsuccessfulLoginKnownUser} />)
|
||||
expect(getByTextContent(`TestUser logged in`)).toBeDefined()
|
||||
const statusPill = screen.getByRole("status")
|
||||
expect(statusPill).toHaveTextContent("401")
|
||||
})
|
||||
it("renders the correct string for unsuccessful login for an unknown user", async () => {
|
||||
render(<AuditLogRow auditLog={MockAuditLogUnsuccessfulLoginUnknownUser} />)
|
||||
expect(getByTextContent(`an unknown user logged in`)).toBeDefined()
|
||||
const statusPill = screen.getByRole("status")
|
||||
expect(statusPill).toHaveTextContent("401")
|
||||
})
|
||||
})
|
||||
|
|
|
@ -12,7 +12,9 @@ export const AuditLogDescription: FC<{ auditLog: AuditLog }> = ({
|
|||
const { t } = i18next
|
||||
|
||||
let target = auditLog.resource_target.trim()
|
||||
let user = auditLog.user?.username.trim()
|
||||
let user = auditLog.user
|
||||
? auditLog.user.username.trim()
|
||||
: t("auditLog:table.logRow.unknownUser")
|
||||
|
||||
if (auditLog.resource_type === "workspace_build") {
|
||||
// audit logs with a resource_type of workspace build use workspace name as a target
|
||||
|
@ -22,7 +24,7 @@ export const AuditLogDescription: FC<{ auditLog: AuditLog }> = ({
|
|||
auditLog.additional_fields?.build_reason &&
|
||||
auditLog.additional_fields?.build_reason !== "initiator"
|
||||
? t("auditLog:table.logRow.buildReason")
|
||||
: auditLog.user?.username.trim()
|
||||
: user
|
||||
}
|
||||
|
||||
// SSH key entries have no links
|
||||
|
|
|
@ -93,7 +93,7 @@ export const AuditLogRow: React.FC<AuditLogRowProps> = ({
|
|||
className={styles.fullWidth}
|
||||
>
|
||||
<UserAvatar
|
||||
username={auditLog.user?.username ?? ""}
|
||||
username={auditLog.user?.username ?? "?"}
|
||||
avatarURL={auditLog.user?.avatar_url}
|
||||
/>
|
||||
|
||||
|
|
|
@ -13,7 +13,8 @@
|
|||
"os": "OS: ",
|
||||
"browser": "Browser: ",
|
||||
"onBehalfOf": " on behalf of {{owner}}",
|
||||
"buildReason": "Coder automatically"
|
||||
"buildReason": "Coder automatically",
|
||||
"unknownUser": "an unknown user"
|
||||
}
|
||||
},
|
||||
"paywall": {
|
||||
|
|
|
@ -1182,6 +1182,26 @@ export const MockAuditLogGitSSH: TypesGen.AuditLog = {
|
|||
},
|
||||
}
|
||||
|
||||
export const MockAuditLogSuccessfulLogin: TypesGen.AuditLog = {
|
||||
...MockAuditLog,
|
||||
resource_type: "api_key",
|
||||
resource_target: "",
|
||||
action: "login",
|
||||
status_code: 201,
|
||||
description: "{user} logged in",
|
||||
}
|
||||
|
||||
export const MockAuditLogUnsuccessfulLoginKnownUser: TypesGen.AuditLog = {
|
||||
...MockAuditLogSuccessfulLogin,
|
||||
status_code: 401,
|
||||
}
|
||||
|
||||
export const MockAuditLogUnsuccessfulLoginUnknownUser: TypesGen.AuditLog = {
|
||||
...MockAuditLogSuccessfulLogin,
|
||||
status_code: 401,
|
||||
user: undefined,
|
||||
}
|
||||
|
||||
export const MockWorkspaceQuota: TypesGen.WorkspaceQuota = {
|
||||
credits_consumed: 0,
|
||||
budget: 100,
|
||||
|
|
Loading…
Reference in New Issue