-
Notifications
You must be signed in to change notification settings - Fork 1.7k
/
orm.go
366 lines (311 loc) · 13.3 KB
/
orm.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
package localauth
import (
"crypto/subtle"
"encoding/json"
"strings"
"time"
"github.com/jmoiron/sqlx"
"github.com/pkg/errors"
"github.com/smartcontractkit/chainlink-common/pkg/utils/mathutil"
"github.com/smartcontractkit/chainlink/v2/core/auth"
"github.com/smartcontractkit/chainlink/v2/core/bridges"
"github.com/smartcontractkit/chainlink/v2/core/logger"
"github.com/smartcontractkit/chainlink/v2/core/logger/audit"
"github.com/smartcontractkit/chainlink/v2/core/services/pg"
"github.com/smartcontractkit/chainlink/v2/core/sessions"
"github.com/smartcontractkit/chainlink/v2/core/utils"
)
type orm struct {
q pg.Q
sessionDuration time.Duration
lggr logger.Logger
auditLogger audit.AuditLogger
}
// orm implements sessions.AuthenticationProvider and sessions.BasicAdminUsersORM interfaces
var _ sessions.AuthenticationProvider = (*orm)(nil)
var _ sessions.BasicAdminUsersORM = (*orm)(nil)
func NewORM(db *sqlx.DB, sd time.Duration, lggr logger.Logger, cfg pg.QConfig, auditLogger audit.AuditLogger) sessions.AuthenticationProvider {
namedLogger := lggr.Named("LocalAuthAuthenticationProviderORM")
return &orm{
q: pg.NewQ(db, namedLogger, cfg),
sessionDuration: sd,
lggr: lggr.Named("LocalAuthAuthenticationProviderORM"),
auditLogger: auditLogger,
}
}
// FindUser will attempt to return an API user by email.
func (o *orm) FindUser(email string) (sessions.User, error) {
return o.findUser(email)
}
// FindUserByAPIToken will attempt to return an API user via the user's table token_key column.
func (o *orm) FindUserByAPIToken(apiToken string) (user sessions.User, err error) {
sql := "SELECT * FROM users WHERE token_key = $1"
err = o.q.Get(&user, sql, apiToken)
return
}
func (o *orm) findUser(email string) (user sessions.User, err error) {
sql := "SELECT * FROM users WHERE lower(email) = lower($1)"
err = o.q.Get(&user, sql, email)
return
}
// ListUsers will load and return all user rows from the db.
func (o *orm) ListUsers() (users []sessions.User, err error) {
sql := "SELECT * FROM users ORDER BY email ASC;"
err = o.q.Select(&users, sql)
return
}
// findValidSession finds an unexpired session by its ID and returns the associated email.
func (o *orm) findValidSession(sessionID string) (email string, err error) {
if err := o.q.Get(&email, "SELECT email FROM sessions WHERE id = $1 AND last_used + $2 >= now() FOR UPDATE", sessionID, o.sessionDuration); err != nil {
o.lggr.Infof("query result: %v", email)
return email, errors.Wrap(err, "no matching user for provided session token")
}
return email, nil
}
// updateSessionLastUsed updates a session by its ID and sets the LastUsed field to now().
func (o *orm) updateSessionLastUsed(sessionID string) error {
return o.q.ExecQ("UPDATE sessions SET last_used = now() WHERE id = $1", sessionID)
}
// AuthorizedUserWithSession will return the API user associated with the Session ID if it
// exists and hasn't expired, and update session's LastUsed field.
// AuthorizedUserWithSession will return the API user associated with the Session ID if it
// exists and hasn't expired, and update session's LastUsed field.
func (o *orm) AuthorizedUserWithSession(sessionID string) (user sessions.User, err error) {
if len(sessionID) == 0 {
return sessions.User{}, sessions.ErrEmptySessionID
}
email, err := o.findValidSession(sessionID)
if err != nil {
return sessions.User{}, sessions.ErrUserSessionExpired
}
user, err = o.findUser(email)
if err != nil {
return sessions.User{}, sessions.ErrUserSessionExpired
}
if err := o.updateSessionLastUsed(sessionID); err != nil {
return sessions.User{}, err
}
return user, nil
}
// DeleteUser will delete an API User and sessions by email.
func (o *orm) DeleteUser(email string) error {
return o.q.Transaction(func(tx pg.Queryer) error {
// session table rows are deleted on cascade through the user email constraint
if _, err := tx.Exec("DELETE FROM users WHERE email = $1", email); err != nil {
return err
}
return nil
})
}
// DeleteUserSession will delete a session by ID.
func (o *orm) DeleteUserSession(sessionID string) error {
_, err := o.q.Exec("DELETE FROM sessions WHERE id = $1", sessionID)
return err
}
// GetUserWebAuthn will return a list of structures representing all enrolled WebAuthn
// tokens for the user. This list must be used when logging in (for obvious reasons) but
// must also be used for registration to prevent the user from enrolling the same hardware
// token multiple times.
func (o *orm) GetUserWebAuthn(email string) ([]sessions.WebAuthn, error) {
var uwas []sessions.WebAuthn
err := o.q.Select(&uwas, "SELECT email, public_key_data FROM web_authns WHERE LOWER(email) = $1", strings.ToLower(email))
if err != nil {
return uwas, err
}
// In the event of not found, there is no MFA on this account and it is not an error
// so this returns either an empty list or list of WebAuthn rows
return uwas, nil
}
// CreateSession will check the password in the SessionRequest against
// the hashed API User password in the db. Also will check WebAuthn if it's
// enabled for that user.
func (o *orm) CreateSession(sr sessions.SessionRequest) (string, error) {
user, err := o.FindUser(sr.Email)
if err != nil {
return "", err
}
lggr := o.lggr.With("user", user.Email)
lggr.Debugw("Found user")
// Do email and password check first to prevent extra database look up
// for MFA tokens leaking if an account has MFA tokens or not.
if !constantTimeEmailCompare(strings.ToLower(sr.Email), strings.ToLower(user.Email)) {
o.auditLogger.Audit(audit.AuthLoginFailedEmail, map[string]interface{}{"email": sr.Email})
return "", errors.New("Invalid email")
}
if !utils.CheckPasswordHash(sr.Password, user.HashedPassword) {
o.auditLogger.Audit(audit.AuthLoginFailedPassword, map[string]interface{}{"email": sr.Email})
return "", errors.New("Invalid password")
}
// Load all valid MFA tokens associated with user's email
uwas, err := o.GetUserWebAuthn(user.Email)
if err != nil {
// There was an error with the database query
lggr.Errorf("Could not fetch user's MFA data: %v", err)
return "", errors.New("MFA Error")
}
// No webauthn tokens registered for the current user, so normal authentication is now complete
if len(uwas) == 0 {
lggr.Infof("No MFA for user. Creating Session")
session := sessions.NewSession()
_, err = o.q.Exec("INSERT INTO sessions (id, email, last_used, created_at) VALUES ($1, $2, now(), now())", session.ID, user.Email)
o.auditLogger.Audit(audit.AuthLoginSuccessNo2FA, map[string]interface{}{"email": sr.Email})
return session.ID, err
}
// Next check if this session request includes the required WebAuthn challenge data
// if not, return a 401 error for the frontend to prompt the user to provide this
// data in the next round trip request (tap key to include webauthn data on the login page)
if sr.WebAuthnData == "" {
lggr.Warnf("Attempted login to MFA user. Generating challenge for user.")
options, webauthnError := sessions.BeginWebAuthnLogin(user, uwas, sr)
if webauthnError != nil {
lggr.Errorf("Could not begin WebAuthn verification: %v", webauthnError)
return "", errors.New("MFA Error")
}
j, jsonError := json.Marshal(options)
if jsonError != nil {
lggr.Errorf("Could not serialize WebAuthn challenge: %v", jsonError)
return "", errors.New("MFA Error")
}
return "", errors.New(string(j))
}
// The user is at the final stage of logging in with MFA. We have an
// attestation back from the user, we now need to verify that it is
// correct.
err = sessions.FinishWebAuthnLogin(user, uwas, sr)
if err != nil {
// The user does have WebAuthn enabled but failed the check
o.auditLogger.Audit(audit.AuthLoginFailed2FA, map[string]interface{}{"email": sr.Email, "error": err})
lggr.Errorf("User sent an invalid attestation: %v", err)
return "", errors.New("MFA Error")
}
lggr.Infof("User passed MFA authentication and login will proceed")
// This is a success so we can create the sessions
session := sessions.NewSession()
_, err = o.q.Exec("INSERT INTO sessions (id, email, last_used, created_at) VALUES ($1, $2, now(), now())", session.ID, user.Email)
if err != nil {
return "", err
}
// Forward registered credentials for audit logs
uwasj, err := json.Marshal(uwas)
if err != nil {
lggr.Errorf("error in Marshal credentials: %s", err)
} else {
o.auditLogger.Audit(audit.AuthLoginSuccessWith2FA, map[string]interface{}{"email": sr.Email, "credential": string(uwasj)})
}
return session.ID, nil
}
const constantTimeEmailLength = 256
func constantTimeEmailCompare(left, right string) bool {
length := mathutil.Max(constantTimeEmailLength, len(left), len(right))
leftBytes := make([]byte, length)
rightBytes := make([]byte, length)
copy(leftBytes, left)
copy(rightBytes, right)
return subtle.ConstantTimeCompare(leftBytes, rightBytes) == 1
}
// ClearNonCurrentSessions removes all sessions but the id passed in.
func (o *orm) ClearNonCurrentSessions(sessionID string) error {
_, err := o.q.Exec("DELETE FROM sessions where id != $1", sessionID)
return err
}
// CreateUser creates a new API user
func (o *orm) CreateUser(user *sessions.User) error {
sql := "INSERT INTO users (email, hashed_password, role, created_at, updated_at) VALUES ($1, $2, $3, now(), now()) RETURNING *"
return o.q.Get(user, sql, strings.ToLower(user.Email), user.HashedPassword, user.Role)
}
// UpdateRole overwrites role field of the user specified by email.
func (o *orm) UpdateRole(email, newRole string) (sessions.User, error) {
var userToEdit sessions.User
if newRole == "" {
return userToEdit, errors.New("user role must be specified")
}
err := o.q.Transaction(func(tx pg.Queryer) error {
// First, attempt to load specified user by email
if err := tx.Get(&userToEdit, "SELECT * FROM users WHERE lower(email) = lower($1)", email); err != nil {
return errors.New("no matching user for provided email")
}
// Patch validated role
userRole, err := sessions.GetUserRole(newRole)
if err != nil {
return err
}
userToEdit.Role = userRole
_, err = tx.Exec("DELETE FROM sessions WHERE email = lower($1)", email)
if err != nil {
o.lggr.Errorf("Failed to purge user sessions for UpdateRole", "err", err)
return errors.New("error updating API user")
}
sql := "UPDATE users SET role = $1, updated_at = now() WHERE lower(email) = lower($2) RETURNING *"
if err := tx.Get(&userToEdit, sql, userToEdit.Role, email); err != nil {
o.lggr.Errorf("Error updating API user", "err", err)
return errors.New("error updating API user")
}
return nil
})
return userToEdit, err
}
// SetAuthToken updates the user to use the given Authentication Token.
func (o *orm) SetPassword(user *sessions.User, newPassword string) error {
hashedPassword, err := utils.HashPassword(newPassword)
if err != nil {
return err
}
sql := "UPDATE users SET hashed_password = $1, updated_at = now() WHERE email = $2 RETURNING *"
return o.q.Get(user, sql, hashedPassword, user.Email)
}
// TestPassword checks plaintext user provided password with hashed database password, returns nil if matched
func (o *orm) TestPassword(email string, password string) error {
var hashedPassword string
if err := o.q.Get(&hashedPassword, "SELECT hashed_password FROM users WHERE lower(email) = lower($1)", email); err != nil {
return errors.New("no matching user for provided email")
}
if !utils.CheckPasswordHash(password, hashedPassword) {
return errors.New("passwords don't match")
}
return nil
}
func (o *orm) CreateAndSetAuthToken(user *sessions.User) (*auth.Token, error) {
newToken := auth.NewToken()
err := o.SetAuthToken(user, newToken)
if err != nil {
return nil, err
}
return newToken, nil
}
// SetAuthToken updates the user to use the given Authentication Token.
func (o *orm) SetAuthToken(user *sessions.User, token *auth.Token) error {
salt := utils.NewSecret(utils.DefaultSecretSize)
hashedSecret, err := auth.HashedSecret(token, salt)
if err != nil {
return errors.Wrap(err, "user")
}
sql := "UPDATE users SET token_salt = $1, token_key = $2, token_hashed_secret = $3, updated_at = now() WHERE email = $4 RETURNING *"
return o.q.Get(user, sql, salt, token.AccessKey, hashedSecret, user.Email)
}
// DeleteAuthToken clears and disables the users Authentication Token.
func (o *orm) DeleteAuthToken(user *sessions.User) error {
sql := "UPDATE users SET token_salt = '', token_key = '', token_hashed_secret = '', updated_at = now() WHERE email = $1 RETURNING *"
return o.q.Get(user, sql, user.Email)
}
// SaveWebAuthn saves new WebAuthn token information.
func (o *orm) SaveWebAuthn(token *sessions.WebAuthn) error {
sql := "INSERT INTO web_authns (email, public_key_data) VALUES ($1, $2)"
_, err := o.q.Exec(sql, token.Email, token.PublicKeyData)
return err
}
// Sessions returns all sessions limited by the parameters.
func (o *orm) Sessions(offset, limit int) (sessions []sessions.Session, err error) {
sql := `SELECT * FROM sessions ORDER BY created_at, id LIMIT $1 OFFSET $2;`
if err = o.q.Select(&sessions, sql, limit, offset); err != nil {
return
}
return
}
// NOTE: this is duplicated from the bridges ORM to appease the AuthStorer interface
func (o *orm) FindExternalInitiator(
eia *auth.Token,
) (*bridges.ExternalInitiator, error) {
exi := &bridges.ExternalInitiator{}
err := o.q.Get(exi, `SELECT * FROM external_initiators WHERE access_key = $1`, eia.AccessKey)
return exi, err
}