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:
Kira Pilot 2023-02-06 15:12:50 -05:00 committed by GitHub
parent 060eeed5c3
commit 46fe59f5e7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
29 changed files with 679 additions and 259 deletions

8
coderd/apidoc/docs.go generated
View File

@ -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": {

View File

@ -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": {

View File

@ -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
}

View File

@ -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 ""

View File

@ -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")
}

View File

@ -16,7 +16,9 @@ CREATE TYPE audit_action AS ENUM (
'write',
'delete',
'start',
'stop'
'stop',
'login',
'logout'
);
CREATE TYPE build_reason AS ENUM (

View File

@ -0,0 +1,2 @@
-- It's not possible to drop enum values from enum types, so the UP has "IF NOT
-- EXISTS".

View File

@ -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';

View File

@ -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,
}
}

View File

@ -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)
})
}

View File

@ -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) {

View File

@ -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)
})
}

View File

@ -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.

View File

@ -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)
})
}

View File

@ -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

View File

@ -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) {

View File

@ -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,

View File

@ -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

View File

@ -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)
})
}

View File

@ -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"
}

View File

@ -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> |

View File

@ -484,6 +484,8 @@
| `delete` |
| `start` |
| `stop` |
| `login` |
| `logout` |
## codersdk.AuditDiff

View File

@ -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

View File

@ -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",

View File

@ -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")
})
})

View File

@ -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

View File

@ -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}
/>

View File

@ -13,7 +13,8 @@
"os": "OS: ",
"browser": "Browser: ",
"onBehalfOf": " on behalf of {{owner}}",
"buildReason": "Coder automatically"
"buildReason": "Coder automatically",
"unknownUser": "an unknown user"
}
},
"paywall": {

View File

@ -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,