main
go 245 lines 6.18 KB
Raw
1 package auth
2
3 import (
4 "errors"
5 "fmt"
6 "strings"
7 "sync"
8 "time"
9
10 "github.com/spruceid/siwe-go"
11
12 "github.com/gosuda/portal-tunnel/v2/portal/identity"
13 "github.com/gosuda/portal-tunnel/v2/types"
14 "github.com/gosuda/portal-tunnel/v2/utils"
15 )
16
17 const (
18 defaultWalletAuthChallengeTTL = 2 * time.Minute
19 defaultWalletAuthSessionTTL = 24 * time.Hour
20 )
21
22 var (
23 ErrWalletAuthUnauthorized = errors.New("wallet is not allowed")
24 ErrWalletAuthChallengeNotFound = errors.New("wallet auth challenge not found")
25 ErrWalletAuthChallengeExpired = errors.New("wallet auth challenge expired")
26 ErrWalletAuthInvalidSignature = errors.New("wallet auth signature is invalid")
27 )
28
29 type WalletAuthConfig struct {
30 AllowedAddresses []string
31 AllowAnyAddress bool
32 Statement string
33 }
34
35 type WalletAuthenticator struct {
36 allowed map[string]struct{}
37 allowAny bool
38 statement string
39
40 mu sync.Mutex
41 challenges map[string]walletAuthChallenge
42 sessions map[string]walletAuthSession
43 }
44
45 type walletAuthChallenge struct {
46 Address string
47 Domain string
48 ExpiresAt time.Time
49 Nonce string
50 SIWEMessage string
51 }
52
53 type walletAuthSession struct {
54 Address string
55 ExpiresAt time.Time
56 }
57
58 func NewWalletAuthenticator(cfg WalletAuthConfig) (*WalletAuthenticator, error) {
59 allowed := make(map[string]struct{}, len(cfg.AllowedAddresses))
60 for _, raw := range cfg.AllowedAddresses {
61 if strings.TrimSpace(raw) == "" {
62 continue
63 }
64 address, err := identity.NormalizeEVMAddress(raw)
65 if err != nil {
66 return nil, fmt.Errorf("wallet address: %w", err)
67 }
68 allowed[strings.ToLower(address)] = struct{}{}
69 }
70 if !cfg.AllowAnyAddress && len(allowed) == 0 {
71 return nil, errors.New("wallet auth requires at least one allowed address")
72 }
73
74 statement := strings.TrimSpace(cfg.Statement)
75 if statement == "" {
76 statement = "Sign in to Portal"
77 }
78
79 return &WalletAuthenticator{
80 allowed: allowed,
81 allowAny: cfg.AllowAnyAddress,
82 statement: statement,
83 challenges: make(map[string]walletAuthChallenge),
84 sessions: make(map[string]walletAuthSession),
85 }, nil
86 }
87
88 func (a *WalletAuthenticator) IssueChallenge(req types.WalletAuthChallengeRequest, domain, uri string, now time.Time) (types.WalletAuthChallengeResponse, error) {
89 if a == nil {
90 return types.WalletAuthChallengeResponse{}, ErrWalletAuthUnauthorized
91 }
92 address, err := identity.NormalizeEVMAddress(req.Address)
93 if err != nil {
94 return types.WalletAuthChallengeResponse{}, err
95 }
96 if !a.addressAllowed(address) {
97 return types.WalletAuthChallengeResponse{}, ErrWalletAuthUnauthorized
98 }
99
100 challengeID := utils.RandomID("wac_")
101 nonce := siwe.GenerateNonce()
102 expiresAt := now.UTC().Add(defaultWalletAuthChallengeTTL)
103 message, err := siwe.InitMessage(domain, address, uri, nonce, map[string]interface{}{
104 "statement": a.statement,
105 "chainId": 1,
106 "issuedAt": now.UTC().Format(time.RFC3339),
107 "expirationTime": expiresAt.UTC().Format(time.RFC3339),
108 "requestId": challengeID,
109 })
110 if err != nil {
111 return types.WalletAuthChallengeResponse{}, fmt.Errorf("build wallet auth message: %w", err)
112 }
113
114 challenge := walletAuthChallenge{
115 Address: address,
116 Domain: strings.TrimSpace(domain),
117 ExpiresAt: expiresAt,
118 Nonce: nonce,
119 SIWEMessage: message.String(),
120 }
121
122 a.mu.Lock()
123 a.cleanupExpiredLocked(now)
124 a.challenges[challengeID] = challenge
125 a.mu.Unlock()
126
127 return types.WalletAuthChallengeResponse{
128 ChallengeID: challengeID,
129 ExpiresAt: expiresAt,
130 SIWEMessage: challenge.SIWEMessage,
131 }, nil
132 }
133
134 func (a *WalletAuthenticator) Login(req types.WalletAuthLoginRequest, now time.Time) (string, string, error) {
135 if a == nil {
136 return "", "", ErrWalletAuthUnauthorized
137 }
138 challengeID := strings.TrimSpace(req.ChallengeID)
139 if challengeID == "" {
140 return "", "", ErrWalletAuthChallengeNotFound
141 }
142
143 a.mu.Lock()
144 a.cleanupExpiredLocked(now)
145 challenge, ok := a.challenges[challengeID]
146 a.mu.Unlock()
147 if !ok {
148 return "", "", ErrWalletAuthChallengeNotFound
149 }
150 if now.After(challenge.ExpiresAt) {
151 a.mu.Lock()
152 delete(a.challenges, challengeID)
153 a.mu.Unlock()
154 return "", "", ErrWalletAuthChallengeExpired
155 }
156 if strings.TrimSpace(req.SIWEMessage) != challenge.SIWEMessage {
157 return "", "", ErrWalletAuthInvalidSignature
158 }
159
160 message, err := siwe.ParseMessage(strings.TrimSpace(req.SIWEMessage))
161 if err != nil {
162 return "", "", ErrWalletAuthInvalidSignature
163 }
164 domain := challenge.Domain
165 nonce := challenge.Nonce
166 verifiedAt := now.UTC()
167 if _, err := message.Verify(strings.TrimSpace(req.SIWESignature), &domain, &nonce, &verifiedAt); err != nil {
168 return "", "", ErrWalletAuthInvalidSignature
169 }
170 address, err := identity.NormalizeEVMAddress(message.GetAddress().Hex())
171 if err != nil {
172 return "", "", ErrWalletAuthInvalidSignature
173 }
174 if !strings.EqualFold(address, challenge.Address) || !a.addressAllowed(address) {
175 return "", "", ErrWalletAuthUnauthorized
176 }
177
178 token := utils.RandomID("was_")
179 a.mu.Lock()
180 delete(a.challenges, challengeID)
181 a.sessions[token] = walletAuthSession{
182 Address: address,
183 ExpiresAt: now.UTC().Add(defaultWalletAuthSessionTTL),
184 }
185 a.cleanupExpiredLocked(now)
186 a.mu.Unlock()
187
188 return token, address, nil
189 }
190
191 func (a *WalletAuthenticator) ValidateSession(token string) (string, bool) {
192 if a == nil {
193 return "", false
194 }
195 token = strings.TrimSpace(token)
196 if token == "" {
197 return "", false
198 }
199
200 a.mu.Lock()
201 defer a.mu.Unlock()
202 session, ok := a.sessions[token]
203 if !ok {
204 return "", false
205 }
206 if time.Now().UTC().After(session.ExpiresAt) {
207 delete(a.sessions, token)
208 return "", false
209 }
210 return session.Address, true
211 }
212
213 func (a *WalletAuthenticator) DeleteSession(token string) {
214 if a == nil {
215 return
216 }
217 a.mu.Lock()
218 defer a.mu.Unlock()
219 delete(a.sessions, strings.TrimSpace(token))
220 }
221
222 func (a *WalletAuthenticator) addressAllowed(address string) bool {
223 if a == nil {
224 return false
225 }
226 if a.allowAny {
227 return true
228 }
229 _, ok := a.allowed[strings.ToLower(strings.TrimSpace(address))]
230 return ok
231 }
232
233 func (a *WalletAuthenticator) cleanupExpiredLocked(now time.Time) {
234 now = now.UTC()
235 for id, challenge := range a.challenges {
236 if now.After(challenge.ExpiresAt) {
237 delete(a.challenges, id)
238 }
239 }
240 for token, session := range a.sessions {
241 if now.After(session.ExpiresAt) {
242 delete(a.sessions, token)
243 }
244 }
245 }