13 changed files with 477 additions and 91 deletions
-
23cmd/stufflog3-local/main.go
-
12entities/user.go
-
18go.mod
-
51go.sum
-
21internal/genutils/find.go
-
13models/errors.go
-
209ports/cognitoauth/client.go
-
72ports/httpapi/auth.go
-
55ports/httpapi/scopes.go
-
13usecases/auth/provider.go
-
29usecases/auth/service.go
-
12usecases/scopes/result.go
-
40usecases/scopes/service.go
@ -0,0 +1,12 @@ |
|||||
|
package entities |
||||
|
|
||||
|
type User struct { |
||||
|
ID string `json:"id"` |
||||
|
} |
||||
|
|
||||
|
type AuthResult struct { |
||||
|
User *User `json:"user"` |
||||
|
Token string `json:"token,omitempty"` |
||||
|
Session string `json:"session,omitempty"` |
||||
|
PasswordChangeRequired bool `json:"passwordChangeRequired"` |
||||
|
} |
@ -0,0 +1,21 @@ |
|||||
|
package genutils |
||||
|
|
||||
|
func Find[T any](arr []T, cb func(t T) bool) *T { |
||||
|
for i, item := range arr { |
||||
|
if cb(item) { |
||||
|
return &arr[i] |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return nil |
||||
|
} |
||||
|
|
||||
|
func Contains[T comparable](arr []T, v T) bool { |
||||
|
for _, item := range arr { |
||||
|
if item == v { |
||||
|
return true |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return false |
||||
|
} |
@ -0,0 +1,209 @@ |
|||||
|
package cognitoauth |
||||
|
|
||||
|
import ( |
||||
|
"context" |
||||
|
"crypto/hmac" |
||||
|
"crypto/sha256" |
||||
|
"encoding/base64" |
||||
|
"encoding/json" |
||||
|
"errors" |
||||
|
"fmt" |
||||
|
"git.aiterp.net/stufflog3/stufflog3/entities" |
||||
|
"git.aiterp.net/stufflog3/stufflog3/models" |
||||
|
"github.com/aws/aws-sdk-go/aws" |
||||
|
"github.com/aws/aws-sdk-go/aws/credentials" |
||||
|
"github.com/aws/aws-sdk-go/aws/session" |
||||
|
"github.com/aws/aws-sdk-go/service/cognitoidentityprovider" |
||||
|
"github.com/dgrijalva/jwt-go/v4" |
||||
|
"github.com/lestrrat-go/jwx/jwa" |
||||
|
"github.com/lestrrat-go/jwx/jwk" |
||||
|
"strings" |
||||
|
) |
||||
|
|
||||
|
type Client struct { |
||||
|
poolID string |
||||
|
poolClientID string |
||||
|
poolClientSecret string |
||||
|
session *session.Session |
||||
|
keySet jwk.Set |
||||
|
} |
||||
|
|
||||
|
func (c *Client) ListUsers(ctx context.Context) ([]entities.User, error) { |
||||
|
cognitoClient := cognitoidentityprovider.New(c.session) |
||||
|
res, err := cognitoClient.ListUsersWithContext(ctx, &cognitoidentityprovider.ListUsersInput{ |
||||
|
UserPoolId: aws.String(c.poolID), |
||||
|
}) |
||||
|
if err != nil { |
||||
|
return nil, err |
||||
|
} |
||||
|
|
||||
|
users := make([]entities.User, 0, 16) |
||||
|
for _, u := range res.Users { |
||||
|
user := entities.User{} |
||||
|
for _, attr := range u.Attributes { |
||||
|
switch *attr.Name { |
||||
|
case "sub": |
||||
|
user.ID = *attr.Value |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
users = append(users, user) |
||||
|
} |
||||
|
|
||||
|
return users, nil |
||||
|
} |
||||
|
|
||||
|
func (c *Client) LoginUser(ctx context.Context, username, password string) (*entities.AuthResult, error) { |
||||
|
mac := hmac.New(sha256.New, []byte(c.poolClientSecret)) |
||||
|
mac.Write([]byte(username + c.poolClientID)) |
||||
|
secretHash := base64.StdEncoding.EncodeToString(mac.Sum(nil)) |
||||
|
|
||||
|
cognitoClient := cognitoidentityprovider.New(c.session) |
||||
|
|
||||
|
res, err := cognitoClient.InitiateAuthWithContext(ctx, &cognitoidentityprovider.InitiateAuthInput{ |
||||
|
AuthFlow: aws.String("USER_PASSWORD_AUTH"), |
||||
|
AuthParameters: map[string]*string{ |
||||
|
"USERNAME": &username, |
||||
|
"PASSWORD": &password, |
||||
|
"SECRET_HASH": aws.String(secretHash), |
||||
|
}, |
||||
|
ClientId: aws.String(c.poolClientID), |
||||
|
}) |
||||
|
if err != nil { |
||||
|
return nil, err |
||||
|
} |
||||
|
|
||||
|
if res.ChallengeName != nil && *res.ChallengeName == "NEW_PASSWORD_REQUIRED" { |
||||
|
return &entities.AuthResult{ |
||||
|
Session: *res.Session, |
||||
|
PasswordChangeRequired: true, |
||||
|
}, nil |
||||
|
} else if res.ChallengeName != nil { |
||||
|
return nil, models.NotImplementedError{ |
||||
|
Function: "cognitoauth.Client", |
||||
|
Message: "Missing handler for challenge: " + *res.ChallengeName, |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
if res.AuthenticationResult == nil || res.AuthenticationResult.IdToken == nil { |
||||
|
return nil, models.PermissionDeniedError{} |
||||
|
} |
||||
|
|
||||
|
idToken := *res.AuthenticationResult.IdToken |
||||
|
|
||||
|
return &entities.AuthResult{ |
||||
|
User: c.ValidateToken(nil, idToken), |
||||
|
Token: idToken, |
||||
|
}, nil |
||||
|
} |
||||
|
|
||||
|
func (c *Client) SetupUser(ctx context.Context, session, username, newPassword string) (*entities.User, error) { |
||||
|
mac := hmac.New(sha256.New, []byte(c.poolClientSecret)) |
||||
|
mac.Write([]byte(username + c.poolClientID)) |
||||
|
secretHash := base64.StdEncoding.EncodeToString(mac.Sum(nil)) |
||||
|
|
||||
|
cognitoClient := cognitoidentityprovider.New(c.session) |
||||
|
|
||||
|
res, err := cognitoClient.RespondToAuthChallengeWithContext(ctx, &cognitoidentityprovider.RespondToAuthChallengeInput{ |
||||
|
ChallengeName: aws.String("NEW_PASSWORD_REQUIRED"), |
||||
|
ChallengeResponses: map[string]*string{ |
||||
|
"NEW_PASSWORD": aws.String(newPassword), |
||||
|
"USERNAME": aws.String(username), |
||||
|
"SECRET_HASH": aws.String(secretHash), |
||||
|
}, |
||||
|
Session: aws.String(session), |
||||
|
ClientId: aws.String(c.poolClientID), |
||||
|
}) |
||||
|
if err != nil { |
||||
|
return nil, err |
||||
|
} |
||||
|
|
||||
|
if res.AuthenticationResult == nil || res.AuthenticationResult.IdToken == nil { |
||||
|
return nil, models.PermissionDeniedError{} |
||||
|
} |
||||
|
|
||||
|
idToken := *res.AuthenticationResult.IdToken |
||||
|
|
||||
|
return c.ValidateToken(ctx, idToken), nil |
||||
|
} |
||||
|
|
||||
|
func (c *Client) ValidateToken(_ context.Context, token string) *entities.User { |
||||
|
_, err := jwt.Parse(token, func(token *jwt.Token) (interface{}, error) { |
||||
|
if token.Method.Alg() != jwa.RS256.String() { // jwa.RS256.String() works as well
|
||||
|
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) |
||||
|
} |
||||
|
kid, ok := token.Header["kid"].(string) |
||||
|
if !ok { |
||||
|
return nil, errors.New("kid header not found") |
||||
|
} |
||||
|
key, ok := c.keySet.LookupKeyID(kid) |
||||
|
if !ok { |
||||
|
return nil, fmt.Errorf("key %v not found", kid) |
||||
|
} |
||||
|
var raw interface{} |
||||
|
err := key.Raw(&raw) |
||||
|
return raw, err |
||||
|
}, jwt.WithoutAudienceValidation()) |
||||
|
if err != nil { |
||||
|
return nil |
||||
|
} |
||||
|
|
||||
|
split := strings.SplitN(token, ".", 3) |
||||
|
if len(split) != 3 { |
||||
|
return nil |
||||
|
} |
||||
|
|
||||
|
data, err := base64.RawStdEncoding.DecodeString(split[1]) |
||||
|
if err != nil { |
||||
|
return nil |
||||
|
} |
||||
|
|
||||
|
m := make(map[string]interface{}, 16) |
||||
|
err = json.Unmarshal(data, &m) |
||||
|
if err != nil { |
||||
|
return nil |
||||
|
} |
||||
|
|
||||
|
userID, _ := m["sub"].(string) |
||||
|
if sub, ok := m["custom:actual_userid"].(string); ok { |
||||
|
userID = sub |
||||
|
} |
||||
|
if actualUserID, ok := m["custom:override_sub"].(string); ok { |
||||
|
userID = actualUserID |
||||
|
} |
||||
|
|
||||
|
return &entities.User{ |
||||
|
ID: userID, |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func New(regionId, clientID, clientSecret, poolId, poolClientId, poolClientSecret string) (*Client, error) { |
||||
|
s, err := session.NewSession(&aws.Config{ |
||||
|
Region: aws.String(regionId), |
||||
|
Credentials: credentials.NewStaticCredentials( |
||||
|
clientID, |
||||
|
clientSecret, |
||||
|
"", |
||||
|
), |
||||
|
}) |
||||
|
if err != nil { |
||||
|
return nil, err |
||||
|
} |
||||
|
|
||||
|
keySet, err := jwk.Fetch(context.Background(), fmt.Sprintf( |
||||
|
"https://cognito-idp.%s.amazonaws.com/%s/.well-known/jwks.json", |
||||
|
regionId, |
||||
|
poolId, |
||||
|
)) |
||||
|
if err != nil { |
||||
|
return nil, err |
||||
|
} |
||||
|
|
||||
|
return &Client{ |
||||
|
poolID: poolId, |
||||
|
poolClientID: poolClientId, |
||||
|
poolClientSecret: poolClientSecret, |
||||
|
session: s, |
||||
|
keySet: keySet, |
||||
|
}, nil |
||||
|
} |
@ -0,0 +1,13 @@ |
|||||
|
package auth |
||||
|
|
||||
|
import ( |
||||
|
"context" |
||||
|
"git.aiterp.net/stufflog3/stufflog3/entities" |
||||
|
) |
||||
|
|
||||
|
type Provider interface { |
||||
|
ListUsers(ctx context.Context) ([]entities.User, error) |
||||
|
LoginUser(ctx context.Context, username, password string) (*entities.AuthResult, error) |
||||
|
SetupUser(ctx context.Context, session, username, newPassword string) (*entities.User, error) |
||||
|
ValidateToken(ctx context.Context, token string) *entities.User |
||||
|
} |
Write
Preview
Loading…
Cancel
Save
Reference in new issue