Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

auth: link slack user #2564

Merged
merged 39 commits into from
Sep 6, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
bdfbc8a
add auth_link_requests table
mastercactapus Jun 22, 2022
0a02707
add authlink Store
mastercactapus Jun 22, 2022
88442cf
pass auth link store through app
mastercactapus Jun 22, 2022
fb533d9
generate auth link for unknown slack user
mastercactapus Jun 22, 2022
599a511
use full param name
mastercactapus Jun 22, 2022
178458a
add link dialog
mastercactapus Jun 22, 2022
6298381
Merge branch 'master' into slack-easy-link
mastercactapus Jun 22, 2022
81675c4
fix permission
mastercactapus Jun 22, 2022
19b7f3c
lower window
mastercactapus Jun 23, 2022
1ea16c7
clear token on success
mastercactapus Jun 23, 2022
7393c76
Merge remote-tracking branch 'origin/master' into slack-easy-link
mastercactapus Jun 23, 2022
f673d38
re-order migration
mastercactapus Jun 23, 2022
896925a
re-order migrations
mastercactapus Aug 2, 2022
12ae537
Merge branch 'master' of https://github.com/target/goalert into slack…
tony-tvu Aug 3, 2022
fabd0dd
add slack link button
tony-tvu Aug 3, 2022
aa4ab10
[WIP] delete ephemeral message
tony-tvu Aug 3, 2022
b6c0fe9
return username as param in auth link
tony-tvu Aug 10, 2022
497db4d
prevent returning 500 error to slack
tony-tvu Aug 10, 2022
7b73f4a
navigate to alert when linked
tony-tvu Aug 11, 2022
0c0ac46
update return type and AuthLinkURL params
tony-tvu Aug 11, 2022
db43284
add error toast and update alert on link success
tony-tvu Aug 11, 2022
c035ad3
format file
tony-tvu Aug 11, 2022
3dea61a
fix post message to display text and button
tony-tvu Aug 16, 2022
3d7f04d
[WIP] add slack linking smoke test
tony-tvu Aug 16, 2022
9c49e19
Merge remote-tracking branch 'origin/master' into slack-easy-link
mastercactapus Aug 16, 2022
4f6b76c
wrap text in section block
mastercactapus Aug 16, 2022
09b9940
remove unused import
mastercactapus Aug 16, 2022
f8c082f
fix ephemeral message assertion
mastercactapus Aug 16, 2022
a46611a
add ui slack link test
tony-tvu Aug 17, 2022
5c351d6
shorten var name
tony-tvu Aug 17, 2022
b86e20a
format and resolve ineffectual assignment to err
tony-tvu Aug 17, 2022
f47c375
Merge remote-tracking branch 'origin/master' into slack-easy-link
mastercactapus Aug 22, 2022
b1b0e77
move metadata to db and add extra info
mastercactapus Aug 22, 2022
831dd1a
linting fixes
mastercactapus Aug 23, 2022
caf6acf
update schema
mastercactapus Aug 23, 2022
aacd329
Merge remote-tracking branch 'origin/master' into slack-easy-link
mastercactapus Aug 29, 2022
a9e2cb0
fix action handling
mastercactapus Aug 29, 2022
384bb8c
fix interaction test
mastercactapus Aug 29, 2022
1630158
remove profile slack test, out of scope
mastercactapus Aug 31, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (
"github.com/target/goalert/alert/alertmetrics"
"github.com/target/goalert/app/lifecycle"
"github.com/target/goalert/auth"
"github.com/target/goalert/auth/authlink"
"github.com/target/goalert/auth/basic"
"github.com/target/goalert/auth/nonce"
"github.com/target/goalert/calsub"
Expand Down Expand Up @@ -116,16 +117,18 @@ type App struct {
LimitStore *limit.Store
HeartbeatStore *heartbeat.Store

OAuthKeyring keyring.Keyring
SessionKeyring keyring.Keyring
APIKeyring keyring.Keyring
OAuthKeyring keyring.Keyring
SessionKeyring keyring.Keyring
APIKeyring keyring.Keyring
AuthLinkKeyring keyring.Keyring

NonceStore *nonce.Store
LabelStore *label.Store
OnCallStore *oncall.Store
NCStore *notificationchannel.Store
TimeZoneStore *timezone.Store
NoticeStore *notice.Store
AuthLinkStore *authlink.Store
}

// NewApp constructs a new App and binds the listening socket.
Expand Down
1 change: 1 addition & 0 deletions app/initengine.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ func (app *App) initEngine(ctx context.Context) error {
NCStore: app.NCStore,
OnCallStore: app.OnCallStore,
ScheduleStore: app.ScheduleStore,
AuthLinkStore: app.AuthLinkStore,

ConfigSource: app.ConfigStore,

Expand Down
6 changes: 3 additions & 3 deletions app/initgraphql.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import (
)

func (app *App) initGraphQL(ctx context.Context) error {

app.graphql2 = &graphqlapp.App{
DB: app.db,
AuthBasicStore: app.AuthBasicStore,
Expand Down Expand Up @@ -35,11 +34,12 @@ func (app *App) initGraphQL(ctx context.Context) error {
NotificationStore: app.NotificationStore,
SlackStore: app.slackChan,
HeartbeatStore: app.HeartbeatStore,
NoticeStore: *app.NoticeStore,
NoticeStore: app.NoticeStore,
Twilio: app.twilioConfig,
AuthHandler: app.AuthHandler,
FormatDestFunc: app.notificationManager.FormatDestValue,
NotificationManager: *app.notificationManager,
NotificationManager: app.notificationManager,
AuthLinkStore: app.AuthLinkStore,
}

return nil
Expand Down
23 changes: 23 additions & 0 deletions app/initstores.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"github.com/target/goalert/alert"
"github.com/target/goalert/alert/alertlog"
"github.com/target/goalert/alert/alertmetrics"
"github.com/target/goalert/auth/authlink"
"github.com/target/goalert/auth/basic"
"github.com/target/goalert/auth/nonce"
"github.com/target/goalert/calsub"
Expand Down Expand Up @@ -78,6 +79,18 @@ func (app *App) initStores(ctx context.Context) error {
return errors.Wrap(err, "init oauth state keyring")
}

if app.AuthLinkKeyring == nil {
app.AuthLinkKeyring, err = keyring.NewDB(ctx, app.cfg.Logger, app.db, &keyring.Config{
Name: "auth-link",
RotationDays: 1,
MaxOldKeys: 1,
Keys: app.cfg.EncryptionKeys,
})
}
if err != nil {
return errors.Wrap(err, "init oauth state keyring")
}

if app.SessionKeyring == nil {
app.SessionKeyring, err = keyring.NewDB(ctx, app.cfg.Logger, app.db, &keyring.Config{
Name: "browser-sessions",
Expand All @@ -101,9 +114,19 @@ func (app *App) initStores(ctx context.Context) error {
return errors.Wrap(err, "init API keyring")
}

if app.AuthLinkStore == nil {
app.AuthLinkStore, err = authlink.NewStore(ctx, app.db, app.AuthLinkKeyring)
}
if err != nil {
return errors.Wrap(err, "init auth link store")
}

if app.AlertMetricsStore == nil {
app.AlertMetricsStore, err = alertmetrics.NewStore(ctx, app.db)
}
if err != nil {
return errors.Wrap(err, "init alert metrics store")
}

if app.AlertLogStore == nil {
app.AlertLogStore, err = alertlog.NewStore(ctx, app.db)
Expand Down
1 change: 1 addition & 0 deletions app/shutdown.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ func (app *App) _Shutdown(ctx context.Context) error {
shut(app.SessionKeyring, "session keyring")
shut(app.OAuthKeyring, "oauth keyring")
shut(app.APIKeyring, "API keyring")
shut(app.AuthLinkKeyring, "auth link keyring")
shut(app.NonceStore, "nonce store")
shut(app.ConfigStore, "config store")
shut(app.requestLock, "context locker")
Expand Down
186 changes: 186 additions & 0 deletions auth/authlink/store.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
package authlink

import (
"context"
"database/sql"
"encoding/json"
"errors"
"net/url"
"time"

"github.com/golang-jwt/jwt/v4"
"github.com/google/uuid"
"github.com/target/goalert/config"
"github.com/target/goalert/keyring"
"github.com/target/goalert/permission"
"github.com/target/goalert/util"
"github.com/target/goalert/validation"
"github.com/target/goalert/validation/validate"
)

type Store struct {
db *sql.DB

k keyring.Keyring

newLink *sql.Stmt
rmLink *sql.Stmt
addSubject *sql.Stmt
findLink *sql.Stmt
}

type Metadata struct {
UserDetails string
AlertID int `json:",omitempty"`
AlertAction string `json:",omitempty"`
}

func (m Metadata) Validate() error {
return validate.Many(
validate.ASCII("UserDetails", m.UserDetails, 1, 255),
validate.OneOf("AlertAction", m.AlertAction, "", "ResultAcknowledge", "ResultResolve"),
)
}

func NewStore(ctx context.Context, db *sql.DB, k keyring.Keyring) (*Store, error) {
p := &util.Prepare{
DB: db,
Ctx: ctx,
}

return &Store{
db: db,
k: k,
newLink: p.P(`insert into auth_link_requests (id, provider_id, subject_id, expires_at, metadata) values ($1, $2, $3, $4, $5)`),
rmLink: p.P(`delete from auth_link_requests where id = $1 and expires_at > now() returning provider_id, subject_id`),
addSubject: p.P(`insert into auth_subjects (provider_id, subject_id, user_id) values ($1, $2, $3)`),
findLink: p.P(`select metadata from auth_link_requests where id = $1 and expires_at > now()`),
}, p.Err
}

func (s *Store) FindLinkMetadata(ctx context.Context, token string) (*Metadata, error) {
err := permission.LimitCheckAny(ctx, permission.User)
if err != nil {
return nil, err
}

tokID, err := s.tokenID(ctx, token)
if err != nil {
// don't return anything, treat it as not found
return nil, nil
}

var meta Metadata
var data json.RawMessage
err = s.findLink.QueryRowContext(ctx, tokID).Scan(&data)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, err
}

err = json.Unmarshal(data, &meta)
if err != nil {
return nil, err
}

return &meta, nil
}

func (s *Store) tokenID(ctx context.Context, token string) (string, error) {
var c jwt.RegisteredClaims
_, err := s.k.VerifyJWT(token, &c)
if err != nil {
return "", validation.WrapError(err)
}

if !c.VerifyIssuer("goalert", true) {
return "", validation.NewGenericError("invalid issuer")
}
if !c.VerifyAudience("auth-link", true) {
return "", validation.NewGenericError("invalid audience")
}
err = validate.UUID("ID", c.ID)
if err != nil {
return "", err
}

return c.ID, nil
}

func (s *Store) LinkAccount(ctx context.Context, token string) error {
err := permission.LimitCheckAny(ctx, permission.User)
if err != nil {
return err
}

tokID, err := s.tokenID(ctx, token)
if err != nil {
return err
}

tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()

var providerID, subjectID string
err = tx.StmtContext(ctx, s.rmLink).QueryRowContext(ctx, tokID).Scan(&providerID, &subjectID)
if errors.Is(err, sql.ErrNoRows) {
return validation.NewGenericError("invalid link token")
}
if err != nil {
return err
}

_, err = tx.StmtContext(ctx, s.addSubject).ExecContext(ctx, providerID, subjectID, permission.UserID(ctx))
if err != nil {
return err
}

return tx.Commit()
}

func (s *Store) AuthLinkURL(ctx context.Context, providerID, subjectID string, meta Metadata) (string, error) {
err := permission.LimitCheckAny(ctx, permission.System)
if err != nil {
return "", err
}
err = validate.Many(
validate.SubjectID("ProviderID", providerID),
validate.SubjectID("SubjectID", subjectID),
meta.Validate(),
)
if err != nil {
return "", err
}

id := uuid.New()
now := time.Now()
expires := now.Add(5 * time.Minute)

var c jwt.RegisteredClaims
c.ID = id.String()
c.Audience = jwt.ClaimStrings{"auth-link"}
c.Issuer = "goalert"
c.NotBefore = jwt.NewNumericDate(now.Add(-2 * time.Minute))
c.ExpiresAt = jwt.NewNumericDate(expires)
c.IssuedAt = jwt.NewNumericDate(now)

token, err := s.k.SignJWT(c)
if err != nil {
return "", err
}

_, err = s.newLink.ExecContext(ctx, id, providerID, subjectID, expires, meta)
if err != nil {
return "", err
}

cfg := config.FromContext(ctx)
p := make(url.Values)
p.Set("authLinkToken", token)
return cfg.CallbackURL("/profile", p), nil
}
53 changes: 50 additions & 3 deletions devtools/mockslack/actions.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ type actionBody struct {
Name string
TeamID string `json:"team_id"`
}
Team struct {
ID string
Domain string
}
ResponseURL string `json:"response_url"`
Actions []actionItem
}
Expand All @@ -42,6 +46,18 @@ func (s *Server) ServeActionResponse(w http.ResponseWriter, r *http.Request) {
var req struct {
Text string
Type string `json:"response_type"`

Blocks []struct {
Type string
Text struct{ Text string }
Elements []struct {
Type string
Text struct{ Text string }
Value string
ActionID string `json:"action_id"`
URL string
}
}
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
Expand All @@ -60,11 +76,40 @@ func (s *Server) ServeActionResponse(w http.ResponseWriter, r *http.Request) {
return
}

msg, err := s.API().ChatPostMessage(r.Context(), ChatPostMessageOptions{
opts := ChatPostMessageOptions{
ChannelID: a.ChannelID,
Text: req.Text,
User: r.URL.Query().Get("user"),
})
}

if len(req.Blocks) > 0 {
// new API
for _, block := range req.Blocks {
switch block.Type {
case "section":
opts.Text = block.Text.Text
case "actions":
for _, action := range block.Elements {
if action.Type != "button" {
continue
}

opts.Actions = append(opts.Actions, Action{
ChannelID: a.ChannelID,
TeamID: a.TeamID,
AppID: a.AppID,
ActionID: action.ActionID,
Text: action.Text.Text,
Value: action.Value,
URL: action.URL,
})
}
}
}
} else {
opts.Text = req.Text
}

msg, err := s.API().ChatPostMessage(r.Context(), opts)
if respondErr(w, err) {
return
}
Expand Down Expand Up @@ -106,6 +151,8 @@ func (s *Server) PerformActionAs(userID string, a Action) error {
p.User.Username = usr.Name
p.User.Name = usr.Name
p.User.TeamID = a.TeamID
p.Team.ID = a.TeamID
p.Team.Domain = "example.com"
p.Channel.ID = a.ChannelID
p.AppID = a.AppID

Expand Down
Loading