api: send OAuth authorization email to user

Currently, the notification email for having authorized a third party
OAuth client is not sent to the acting user, but instead to the owner of
the client. This commit fixes that.

The OAuth2 third party client authorization notification email is
triggered by a request from the third party client to the
`/oauth2/authorize` enpoint (see RFC 6749 section 4.1.1 [1]). This
request has no user authentication in the sense of the auth middleware.
Instead, it happens when processing the OAuth parameters, after all
middlewares. Hence, `auth.ForContext()` returns a context that contains
the OAuth client and its owner, but not the user performing the action.
Due to that, the email currently gets send to the owner.

To rectify, provide a `sendSecurityNotificationTo()` function that does
not use the current auth context at all and use it for this specific use
case. The regular `sendSecurityNotification()` just becomes a thin
wrapper around that function providing all values from the current auth
context.

While at it, tweak it to also get the PGP key from the context, because
that's what was used on all call sites anyway.

[1]: https://www.rfc-editor.org/rfc/rfc6749#section-4.1.1
This commit is contained in:
Conrad Hoffmann 2023-02-24 11:12:02 +01:00 committed by Drew DeVault
parent 57c05a91f5
commit 835f74056f
2 changed files with 52 additions and 36 deletions

View File

@ -53,6 +53,28 @@ func recordAuditLog(ctx context.Context, eventType, details string) {
})
}
func pgpKeyForUser(ctx context.Context, user *model.User) (*string, error) {
var key *string
if user.PGPKeyID != nil {
if err := database.WithTx(ctx, &sql.TxOptions{
Isolation: 0,
ReadOnly: true,
}, func(tx *sql.Tx) error {
row := tx.QueryRowContext(ctx, `
SELECT key
FROM "pgpkey" WHERE id = $1;
`, *user.PGPKeyID)
if err := row.Scan(&key); err != nil {
return err
}
return nil
}); err != nil {
return nil, err
}
}
return key, nil
}
func sendRegistrationConfirmation(ctx context.Context,
user *model.User, pgpKey *string, confirmation string) {
conf := config.ForContext(ctx)
@ -159,9 +181,17 @@ func sendEmailNotification(ctx context.Context,
return email.EnqueueStd(ctx, header, p.Body, pgpKey)
}
// Sends a security-related notice to the authorized user.
func sendSecurityNotification(ctx context.Context,
subject, details string, pgpKey *string) {
// Send a security-related notice to the authorized user.
func sendSecurityNotification(ctx context.Context, subject, details string) {
user := auth.ForContext(ctx)
sendSecurityNotificationTo(ctx, user.Username, user.Email,
subject, details, user.PGPKey)
}
// Send a security-related notice to the given user.
// Always prefer using `sendSecurityNotification` if possible.
func sendSecurityNotificationTo(ctx context.Context,
username, address, subject, details string, pgpKey *string) {
conf := config.ForContext(ctx)
siteName, ok := conf.Get("sr.ht", "site-name")
if !ok {
@ -172,10 +202,9 @@ func sendSecurityNotification(ctx context.Context,
panic(fmt.Errorf("Expected [sr.ht]owner-name in config"))
}
user := auth.ForContext(ctx)
var header mail.Header
header.SetAddressList("To", []*mail.Address{
&mail.Address{user.Username, user.Email},
&mail.Address{username, address},
})
header.SetSubject(subject)
@ -188,7 +217,7 @@ func sendSecurityNotification(ctx context.Context,
tctx := TemplateContext{
OwnerName: ownerName,
SiteName: siteName,
Username: user.Username,
Username: username,
Details: details,
}

View File

@ -278,8 +278,7 @@ func (r *mutationResolver) CreatePGPKey(ctx context.Context, key string) (*model
fingerprint := strings.ToUpper(hex.EncodeToString(pkey.Fingerprint[:]))
sendSecurityNotification(ctx,
fmt.Sprintf("A PGP key was added to your %s account", siteName),
fmt.Sprintf("PGP key %s added to your account", fingerprint),
auth.ForContext(ctx).PGPKey)
fmt.Sprintf("PGP key %s added to your account", fingerprint))
recordAuditLog(ctx, "PGP key added", fmt.Sprintf("PGP key %s added", fingerprint))
mkey := &model.PGPKey{
@ -340,8 +339,7 @@ func (r *mutationResolver) DeletePGPKey(ctx context.Context, id int) (*model.PGP
fingerprint := strings.ToUpper(hex.EncodeToString(key.RawFingerprint))
sendSecurityNotification(ctx,
fmt.Sprintf("A PGP key was removed from your %s account", siteName),
fmt.Sprintf("PGP key %s removed from your account", fingerprint),
auth.ForContext(ctx).PGPKey)
fmt.Sprintf("PGP key %s removed from your account", fingerprint))
recordAuditLog(ctx, "PGP key removed",
fmt.Sprintf("PGP key %s removed", fingerprint))
webhooks.DeliverPGPKeyEvent(ctx, model.WebhookEventPGPKeyRemoved, &key)
@ -411,8 +409,7 @@ func (r *mutationResolver) CreateSSHKey(ctx context.Context, key string) (*model
}
sendSecurityNotification(ctx,
fmt.Sprintf("An SSH key was added to your %s account", siteName),
fmt.Sprintf("SSH key %s added to your account", fingerprint),
auth.ForContext(ctx).PGPKey)
fmt.Sprintf("SSH key %s added to your account", fingerprint))
recordAuditLog(ctx, "SSH key added",
fmt.Sprintf("SSH key %s added", fingerprint))
@ -467,8 +464,7 @@ func (r *mutationResolver) DeleteSSHKey(ctx context.Context, id int) (*model.SSH
sendSecurityNotification(ctx,
fmt.Sprintf("An SSH key was removed from your %s account", siteName),
fmt.Sprintf("SSH key %s removed from your account", key.Fingerprint),
auth.ForContext(ctx).PGPKey)
fmt.Sprintf("SSH key %s removed from your account", key.Fingerprint))
recordAuditLog(ctx, "SSH key removed",
fmt.Sprintf("SSH key %s removed", key.Fingerprint))
webhooks.DeliverSSHKeyEvent(ctx, model.WebhookEventSSHKeyRemoved, &key)
@ -994,8 +990,7 @@ func (r *mutationResolver) IssuePersonalAccessToken(ctx context.Context, grants
}
sendSecurityNotification(ctx,
fmt.Sprintf("A personal access token was issued for your %s account", siteName),
"An OAuth 2.0 personal access token was issued for your account",
auth.ForContext(ctx).PGPKey)
"An OAuth 2.0 personal access token was issued for your account")
return &model.OAuthPersonalTokenRegistration{
Token: &model.OAuthPersonalToken{
@ -1112,6 +1107,11 @@ func (r *mutationResolver) IssueOAuthGrant(ctx context.Context, authorization st
if err != nil {
panic(err)
}
pgpKey, err := pgpKeyForUser(ctx, user)
if err != nil {
// Ignore error, worst case the notification email is unencrypted
log.Printf("failed to get PGP key for user %d: %s", user.ID, err.Error())
}
client, err := loaders.ForContext(ctx).
OAuthClientsByUUID.Load(payload.ClientUUID)
if err != nil {
@ -1166,10 +1166,11 @@ func (r *mutationResolver) IssueOAuthGrant(ctx context.Context, authorization st
if !ok {
panic(fmt.Errorf("Expected [sr.ht]site-name in config"))
}
sendSecurityNotification(ctx,
sendSecurityNotificationTo(ctx,
user.Username, user.Email,
fmt.Sprintf("A third party has been granted access to your %s account", siteName),
fmt.Sprintf("An OAuth 2.0 bearer grant for your account was issued to %s", client.Name),
auth.ForContext(ctx).PGPKey)
pgpKey)
return &model.OAuthGrantRegistration{
Grant: &model.OAuthGrant{
@ -1193,24 +1194,10 @@ func (r *mutationResolver) SendEmailNotification(ctx context.Context, username s
if user == nil {
return false, fmt.Errorf("Email notification request to unknown user: %s", user)
}
var key *string
if user.PGPKeyID != nil {
if err := database.WithTx(ctx, &sql.TxOptions{
Isolation: 0,
ReadOnly: true,
}, func(tx *sql.Tx) error {
row := tx.QueryRowContext(ctx, `
SELECT key
FROM "pgpkey" WHERE id = $1;
`, *user.PGPKeyID)
if err := row.Scan(&key); err != nil {
return err
}
return nil
}); err != nil {
return false, err
}
key, err := pgpKeyForUser(ctx, user)
if err != nil {
// Ignore error, worst case the notification email is unencrypted
log.Printf("failed to get PGP key for user %d: %s", user.ID, err.Error())
}
err = sendEmailNotification(ctx, user.Username, user.Email, message, key)
return err == nil, err