Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
130 changes: 130 additions & 0 deletions pkg/admin/graphql/fraud_protection_overview.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,40 @@ import (
"github.com/authgear/authgear-server/pkg/lib/audit"
)

var fraudProtectionOverviewTimeBucketType = graphql.NewObject(graphql.ObjectConfig{
Name: "FraudProtectionOverviewTimeBucket",
Fields: graphql.Fields{
"hour": &graphql.Field{
Type: graphql.NewNonNull(graphql.DateTime),
Resolve: func(p graphql.ResolveParams) (any, error) {
source := p.Source.(audit.FraudProtectionOverviewTimeBucket)
return source.Hour, nil
},
},
"total": &graphql.Field{
Type: graphql.NewNonNull(graphql.Int),
Resolve: func(p graphql.ResolveParams) (any, error) {
source := p.Source.(audit.FraudProtectionOverviewTimeBucket)
return source.Total, nil
},
},
"blocked": &graphql.Field{
Type: graphql.NewNonNull(graphql.Int),
Resolve: func(p graphql.ResolveParams) (any, error) {
source := p.Source.(audit.FraudProtectionOverviewTimeBucket)
return source.Blocked, nil
},
},
"flagged": &graphql.Field{
Type: graphql.NewNonNull(graphql.Int),
Resolve: func(p graphql.ResolveParams) (any, error) {
source := p.Source.(audit.FraudProtectionOverviewTimeBucket)
return source.Flagged, nil
},
},
},
})

var fraudProtectionOverviewTopSourceIPType = graphql.NewObject(graphql.ObjectConfig{
Name: "FraudProtectionOverviewTopSourceIP",
Fields: graphql.Fields{
Expand All @@ -16,6 +50,13 @@ var fraudProtectionOverviewTopSourceIPType = graphql.NewObject(graphql.ObjectCon
return source.IPAddress, nil
},
},
"geoCountryCode": &graphql.Field{
Type: graphql.NewNonNull(graphql.String),
Resolve: func(p graphql.ResolveParams) (any, error) {
source := p.Source.(audit.FraudProtectionOverviewIP)
return source.GeoCountryCode, nil
},
},
"total": &graphql.Field{
Type: graphql.NewNonNull(graphql.Int),
Resolve: func(p graphql.ResolveParams) (any, error) {
Expand All @@ -40,6 +81,74 @@ var fraudProtectionOverviewTopSourceIPType = graphql.NewObject(graphql.ObjectCon
},
})

var fraudProtectionOverviewIPLocationType = graphql.NewObject(graphql.ObjectConfig{
Name: "FraudProtectionOverviewIPLocation",
Fields: graphql.Fields{
"geoCountryCode": &graphql.Field{
Type: graphql.NewNonNull(graphql.String),
Resolve: func(p graphql.ResolveParams) (any, error) {
source := p.Source.(audit.FraudProtectionOverviewIPLocation)
return source.GeoCountryCode, nil
},
},
"total": &graphql.Field{
Type: graphql.NewNonNull(graphql.Int),
Resolve: func(p graphql.ResolveParams) (any, error) {
source := p.Source.(audit.FraudProtectionOverviewIPLocation)
return source.Total, nil
},
},
"blocked": &graphql.Field{
Type: graphql.NewNonNull(graphql.Int),
Resolve: func(p graphql.ResolveParams) (any, error) {
source := p.Source.(audit.FraudProtectionOverviewIPLocation)
return source.Blocked, nil
},
},
"flagged": &graphql.Field{
Type: graphql.NewNonNull(graphql.Int),
Resolve: func(p graphql.ResolveParams) (any, error) {
source := p.Source.(audit.FraudProtectionOverviewIPLocation)
return source.Flagged, nil
},
},
},
})

var fraudProtectionOverviewSMSOriginType = graphql.NewObject(graphql.ObjectConfig{
Name: "FraudProtectionOverviewSMSOrigin",
Fields: graphql.Fields{
"phoneCountryCode": &graphql.Field{
Type: graphql.NewNonNull(graphql.String),
Resolve: func(p graphql.ResolveParams) (any, error) {
source := p.Source.(audit.FraudProtectionOverviewSMSOrigin)
return source.PhoneCountryCode, nil
},
},
"total": &graphql.Field{
Type: graphql.NewNonNull(graphql.Int),
Resolve: func(p graphql.ResolveParams) (any, error) {
source := p.Source.(audit.FraudProtectionOverviewSMSOrigin)
return source.Total, nil
},
},
"blocked": &graphql.Field{
Type: graphql.NewNonNull(graphql.Int),
Resolve: func(p graphql.ResolveParams) (any, error) {
source := p.Source.(audit.FraudProtectionOverviewSMSOrigin)
return source.Blocked, nil
},
},
"flagged": &graphql.Field{
Type: graphql.NewNonNull(graphql.Int),
Resolve: func(p graphql.ResolveParams) (any, error) {
source := p.Source.(audit.FraudProtectionOverviewSMSOrigin)
return source.Flagged, nil
},
},
},
})

var fraudProtectionOverviewSendSMSType = graphql.NewObject(graphql.ObjectConfig{
Name: "FraudProtectionOverviewSendSMS",
Fields: graphql.Fields{
Expand Down Expand Up @@ -71,6 +180,27 @@ var fraudProtectionOverviewSendSMSType = graphql.NewObject(graphql.ObjectConfig{
return source.TopSourceIPs, nil
},
},
"topIPLocations": &graphql.Field{
Type: graphql.NewNonNull(graphql.NewList(graphql.NewNonNull(fraudProtectionOverviewIPLocationType))),
Resolve: func(p graphql.ResolveParams) (any, error) {
source := p.Source.(audit.FraudProtectionOverviewSendSMS)
return source.TopIPLocations, nil
},
},
"topSMSOrigins": &graphql.Field{
Type: graphql.NewNonNull(graphql.NewList(graphql.NewNonNull(fraudProtectionOverviewSMSOriginType))),
Resolve: func(p graphql.ResolveParams) (any, error) {
source := p.Source.(audit.FraudProtectionOverviewSendSMS)
return source.TopSMSOrigins, nil
},
},
"timeBuckets": &graphql.Field{
Type: graphql.NewNonNull(graphql.NewList(graphql.NewNonNull(fraudProtectionOverviewTimeBucketType))),
Resolve: func(p graphql.ResolveParams) (any, error) {
source := p.Source.(audit.FraudProtectionOverviewSendSMS)
return source.TimeBuckets, nil
},
},
},
})

Expand Down
156 changes: 147 additions & 9 deletions pkg/lib/audit/fraud_protection_overview.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,17 +17,42 @@ type FraudProtectionOverview struct {
}

type FraudProtectionOverviewSendSMS struct {
TotalActions int `json:"totalActions"`
BlockedActions int `json:"blockedActions"`
WarnedActions int `json:"warnedActions"`
TopSourceIPs []FraudProtectionOverviewIP `json:"topSourceIPs"`
TotalActions int `json:"totalActions"`
BlockedActions int `json:"blockedActions"`
WarnedActions int `json:"warnedActions"`
TopSourceIPs []FraudProtectionOverviewIP `json:"topSourceIPs"`
TopIPLocations []FraudProtectionOverviewIPLocation `json:"topIPLocations"`
TopSMSOrigins []FraudProtectionOverviewSMSOrigin `json:"topSMSOrigins"`
TimeBuckets []FraudProtectionOverviewTimeBucket `json:"timeBuckets"`
}

type FraudProtectionOverviewTimeBucket struct {
Hour time.Time `json:"hour"`
Total int `json:"total"`
Blocked int `json:"blocked"`
Flagged int `json:"flagged"`
}

type FraudProtectionOverviewIP struct {
IPAddress string `json:"ipAddress"`
Total int `json:"total"`
Blocked int `json:"blocked"`
Flagged int `json:"flagged"`
IPAddress string `json:"ipAddress"`
GeoCountryCode string `json:"geoCountryCode"`
Total int `json:"total"`
Blocked int `json:"blocked"`
Flagged int `json:"flagged"`
}

type FraudProtectionOverviewSMSOrigin struct {
PhoneCountryCode string `json:"phoneCountryCode"`
Total int `json:"total"`
Blocked int `json:"blocked"`
Flagged int `json:"flagged"`
}

type FraudProtectionOverviewIPLocation struct {
GeoCountryCode string `json:"geoCountryCode"`
Total int `json:"total"`
Blocked int `json:"blocked"`
Flagged int `json:"flagged"`
}

type FraudProtectionOverviewQueryOptions struct {
Expand Down Expand Up @@ -98,6 +123,7 @@ func (s *ReadStore) GetFraudProtectionOverview(ctx context.Context, opts FraudPr
topIPsQuery := s.SQLBuilder.
Select(
"ip_address",
`COALESCE(MODE() WITHIN GROUP (ORDER BY NULLIF(UPPER(data#>>'{payload,record,geo_location_code}'), '')), '') AS geo_country_code`,
"COUNT(*) AS total_actions",
"COUNT(*) FILTER (WHERE decision = 'blocked') AS blocked_actions",
"COUNT(*) FILTER (WHERE decision = 'allowed' AND warning_count > 0) AS warning_actions",
Expand All @@ -121,7 +147,7 @@ func (s *ReadStore) GetFraudProtectionOverview(ctx context.Context, opts FraudPr
var total int64
var blocked int64
var warnings int64
if err := rows.Scan(&ip, &total, &blocked, &warnings); err != nil {
if err := rows.Scan(&ip, &item.GeoCountryCode, &total, &blocked, &warnings); err != nil {
return nil, err
}
if ip.Valid {
Expand All @@ -136,12 +162,124 @@ func (s *ReadStore) GetFraudProtectionOverview(ctx context.Context, opts FraudPr
return nil, err
}

topSMSOriginsQuery := s.SQLBuilder.
Select(
"COALESCE(data#>>'{payload,record,action_detail,phone_number_country_code}', '') AS phone_country_code",
"COUNT(*) AS total_actions",
"COUNT(*) FILTER (WHERE decision = 'blocked') AS blocked_actions",
"COUNT(*) FILTER (WHERE decision = 'allowed' AND warning_count > 0) AS warning_actions",
).
FromSelect(baseQuery, "records").
Where("COALESCE(data#>>'{payload,record,action_detail,phone_number_country_code}', '') <> ''").
GroupBy("phone_country_code").
OrderBy("total_actions DESC", "phone_country_code ASC").
Limit(10)

smsRows, err := s.SQLExecutor.QueryWith(ctx, topSMSOriginsQuery)
if err != nil {
return nil, err
}
defer smsRows.Close()

topSMSOrigins := make([]FraudProtectionOverviewSMSOrigin, 0)
for smsRows.Next() {
var item FraudProtectionOverviewSMSOrigin
var total int64
var blocked int64
var warnings int64
if err := smsRows.Scan(&item.PhoneCountryCode, &total, &blocked, &warnings); err != nil {
return nil, err
}
item.Total = int(total)
item.Blocked = int(blocked)
item.Flagged = int(warnings)
topSMSOrigins = append(topSMSOrigins, item)
}
if err := smsRows.Err(); err != nil {
return nil, err
}

topIPLocationsQuery := s.SQLBuilder.
Select(
"COALESCE(UPPER(data#>>'{payload,record,geo_location_code}'), '') AS geo_country_code",
"COUNT(*) AS total_actions",
"COUNT(*) FILTER (WHERE decision = 'blocked') AS blocked_actions",
"COUNT(*) FILTER (WHERE decision = 'allowed' AND warning_count > 0) AS warning_actions",
).
FromSelect(baseQuery, "records").
Where("COALESCE(data#>>'{payload,record,geo_location_code}', '') <> ''").
GroupBy("geo_country_code").
OrderBy("total_actions DESC", "geo_country_code ASC").
Limit(10)

ipLocationRows, err := s.SQLExecutor.QueryWith(ctx, topIPLocationsQuery)
if err != nil {
return nil, err
}
defer ipLocationRows.Close()

topIPLocations := make([]FraudProtectionOverviewIPLocation, 0)
for ipLocationRows.Next() {
var item FraudProtectionOverviewIPLocation
var total int64
var blocked int64
var warnings int64
if err := ipLocationRows.Scan(&item.GeoCountryCode, &total, &blocked, &warnings); err != nil {
return nil, err
}
item.Total = int(total)
item.Blocked = int(blocked)
item.Flagged = int(warnings)
topIPLocations = append(topIPLocations, item)
}
if err := ipLocationRows.Err(); err != nil {
return nil, err
}

timeBucketsQuery := s.SQLBuilder.
Select(
"DATE_TRUNC('hour', created_at) AS hour",
"COUNT(*) AS total_actions",
"COUNT(*) FILTER (WHERE decision = 'blocked') AS blocked_actions",
"COUNT(*) FILTER (WHERE decision = 'allowed' AND warning_count > 0) AS warning_actions",
).
FromSelect(baseQuery, "records").
GroupBy("hour").
OrderBy("hour ASC")

timeBucketRows, err := s.SQLExecutor.QueryWith(ctx, timeBucketsQuery)
if err != nil {
return nil, err
}
defer timeBucketRows.Close()

timeBuckets := make([]FraudProtectionOverviewTimeBucket, 0)
for timeBucketRows.Next() {
var item FraudProtectionOverviewTimeBucket
var total int64
var blocked int64
var warnings int64
if err := timeBucketRows.Scan(&item.Hour, &total, &blocked, &warnings); err != nil {
return nil, err
}
item.Total = int(total)
item.Blocked = int(blocked)
item.Flagged = int(warnings)
timeBuckets = append(timeBuckets, item)
}
if err := timeBucketRows.Err(); err != nil {
return nil, err
}

return &FraudProtectionOverview{
SendSMS: FraudProtectionOverviewSendSMS{
TotalActions: int(totalActions),
BlockedActions: int(blockedActions),
WarnedActions: int(warnedActions),
TopSourceIPs: topSourceIPs,
TopIPLocations: topIPLocations,
TopSMSOrigins: topSMSOrigins,
TimeBuckets: timeBuckets,
},
}, nil
}
18 changes: 16 additions & 2 deletions pkg/lib/audit/read_store.go
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,13 @@ func (s *ReadStore) queryFraudProtectionDecisionRecordsBase(
opts FraudProtectionDecisionRecordQueryOptions,
) db.SelectBuilder {
query := s.SQLBuilder.
Select("id", "created_at", "data").
Select(
"id",
"created_at",
"data",
"COALESCE(host(ip_address)::text, '') AS ip_address",
"COALESCE(user_agent, '') AS user_agent",
).
From(s.SQLBuilder.TableName("_audit_log"))
return opts.Apply(query)
}
Expand Down Expand Up @@ -299,7 +305,9 @@ func (s *ReadStore) scanFraudProtectionDecisionRecord(
) (*FraudProtectionDecisionRecord, error) {
record := &FraudProtectionDecisionRecord{}
var raw []byte
if err := scn.Scan(&record.ID, &record.CreatedAt, &raw); err != nil {
var columnIPAddress string
var columnUserAgent string
if err := scn.Scan(&record.ID, &record.CreatedAt, &raw, &columnIPAddress, &columnUserAgent); err != nil {
return nil, err
}

Expand All @@ -312,5 +320,11 @@ func (s *ReadStore) scanFraudProtectionDecisionRecord(
return nil, err
}
record.Record = payload.Payload.Record
if record.Record.IPAddress == "" && columnIPAddress != "" {
record.Record.IPAddress = columnIPAddress
}
if record.Record.UserAgent == "" && columnUserAgent != "" {
record.Record.UserAgent = columnUserAgent
}
return record, nil
}
Loading