diff options
Diffstat (limited to 'app/models')
-rw-r--r-- | app/models/account.go | 115 | ||||
-rw-r--r-- | app/models/session_key.go | 202 | ||||
-rw-r--r-- | app/models/user.go | 99 |
3 files changed, 416 insertions, 0 deletions
diff --git a/app/models/account.go b/app/models/account.go new file mode 100644 index 0000000..0ae1821 --- /dev/null +++ b/app/models/account.go | |||
@@ -0,0 +1,115 @@ | |||
1 | package models | ||
2 | |||
3 | import ( | ||
4 | "context" | ||
5 | "fmt" | ||
6 | "time" | ||
7 | |||
8 | "code.crute.us/mcrute/golib/db/mongodb" | ||
9 | ) | ||
10 | |||
11 | const accountCol = "accounts" | ||
12 | |||
13 | type AccountStore interface { | ||
14 | List(context.Context) ([]*Account, error) | ||
15 | ListForUser(context.Context, *User) ([]*Account, error) | ||
16 | Get(context.Context, string) (*Account, error) // Error on not found | ||
17 | GetForUser(context.Context, string, *User) (*Account, error) // Error on not found | ||
18 | Put(context.Context, *Account) error | ||
19 | Delete(context.Context, *Account) error | ||
20 | } | ||
21 | |||
22 | type Account struct { | ||
23 | ShortName string `bson:"_id"` | ||
24 | AccountType string | ||
25 | AccountNumber int | ||
26 | Name string | ||
27 | ConsoleSessionDuration time.Duration | ||
28 | VaultMaterial string | ||
29 | DefaultRegion string | ||
30 | Users []string | ||
31 | } | ||
32 | |||
33 | func (a *Account) ConsoleSessionDurationSecs() int64 { | ||
34 | return int64(a.ConsoleSessionDuration.Seconds()) | ||
35 | } | ||
36 | |||
37 | func (a *Account) CanAccess(u *User) bool { | ||
38 | if u.IsAdmin { | ||
39 | return true | ||
40 | } | ||
41 | // Linear search should be fine for now, these lists are pretty small | ||
42 | for _, n := range a.Users { | ||
43 | if n == u.Username { | ||
44 | return true | ||
45 | } | ||
46 | } | ||
47 | return false | ||
48 | } | ||
49 | |||
50 | type MongoDbAccountStore struct { | ||
51 | Db *mongodb.Mongo | ||
52 | } | ||
53 | |||
54 | // List returns all accounts in the system. | ||
55 | func (s *MongoDbAccountStore) List(ctx context.Context) ([]*Account, error) { | ||
56 | var out []*Account | ||
57 | if err := s.Db.FindAll(ctx, accountCol, &out); err != nil { | ||
58 | return nil, err | ||
59 | } | ||
60 | return out, nil | ||
61 | } | ||
62 | |||
63 | // ListForUser returns all accounts for which the user has access. This is the | ||
64 | // authorized version of List. | ||
65 | // | ||
66 | // Note this does not handle the case where a user is an admin but not | ||
67 | // explicitly listed in the allowed users list for an account. For that case | ||
68 | // just use List directly. | ||
69 | func (s *MongoDbAccountStore) ListForUser(ctx context.Context, u *User) ([]*Account, error) { | ||
70 | var out []*Account | ||
71 | filter := mongodb.AnyInTopLevelArray("Users", u.Username) | ||
72 | if err := s.Db.FindAllByFilter(ctx, accountCol, filter, &out); err != nil { | ||
73 | return nil, err | ||
74 | } | ||
75 | return out, nil | ||
76 | } | ||
77 | |||
78 | func (s *MongoDbAccountStore) Get(ctx context.Context, id string) (*Account, error) { | ||
79 | var a Account | ||
80 | if err := s.Db.FindOneById(ctx, accountCol, id, &a); err != nil { | ||
81 | return nil, err | ||
82 | } | ||
83 | return &a, nil | ||
84 | } | ||
85 | |||
86 | // GetForUser returns an account if the user has access to this account, | ||
87 | // otherwise it returns an error. This is the authorized version of Get. | ||
88 | func (s *MongoDbAccountStore) GetForUser(ctx context.Context, id string, u *User) (*Account, error) { | ||
89 | a, err := s.Get(ctx, id) | ||
90 | if err != nil { | ||
91 | return nil, err | ||
92 | } | ||
93 | |||
94 | if !a.CanAccess(u) { | ||
95 | return nil, fmt.Errorf("User does not have access to account") | ||
96 | } | ||
97 | |||
98 | return a, nil | ||
99 | } | ||
100 | |||
101 | func (s *MongoDbAccountStore) Put(ctx context.Context, a *Account) error { | ||
102 | if err := s.Db.ReplaceOneById(ctx, accountCol, a.ShortName, a); err != nil { | ||
103 | return err | ||
104 | } | ||
105 | return nil | ||
106 | } | ||
107 | |||
108 | func (s *MongoDbAccountStore) Delete(ctx context.Context, a *Account) error { | ||
109 | if err := s.Db.DeleteOneById(ctx, accountCol, a.ShortName); err != nil { | ||
110 | return err | ||
111 | } | ||
112 | return nil | ||
113 | } | ||
114 | |||
115 | var _ AccountStore = (*MongoDbAccountStore)(nil) | ||
diff --git a/app/models/session_key.go b/app/models/session_key.go new file mode 100644 index 0000000..64ac7e0 --- /dev/null +++ b/app/models/session_key.go | |||
@@ -0,0 +1,202 @@ | |||
1 | package models | ||
2 | |||
3 | import ( | ||
4 | "crypto" | ||
5 | "crypto/ecdsa" | ||
6 | "crypto/elliptic" | ||
7 | "crypto/rand" | ||
8 | "crypto/x509" | ||
9 | "encoding/base64" | ||
10 | "encoding/hex" | ||
11 | "fmt" | ||
12 | "time" | ||
13 | |||
14 | "go.mongodb.org/mongo-driver/bson" | ||
15 | ) | ||
16 | |||
17 | // SessionKey represents a public and sometimes private key-pair for a user | ||
18 | // that will be stored on the user's record in the user store. These keys are | ||
19 | // used for signing authentication JWTs. | ||
20 | // | ||
21 | // This object is designed to be serialized to BSON. Other serializations can | ||
22 | // be added in the future as needed. | ||
23 | // | ||
24 | // There are two flavors of this record. A record with a private key (which | ||
25 | // implies a public key) is a key that the service generated and is used by the | ||
26 | // service to sign JWTs for the user. The private key is never given to the | ||
27 | // user. The private key is only used in the CreateToken flow, never the Verify | ||
28 | // flow. Currently (as of Nov 2021) the application sets a near-future NotAfter | ||
29 | // date and these get garbage collected. It might be nice to re-use them in the | ||
30 | // future for a while but it's not all that important. | ||
31 | // | ||
32 | // The other flavor of this key will have a public key but no private key. | ||
33 | // These are service keys. Service keys are given to programmatic actors that | ||
34 | // need to be able to mint their own JWTs for authentication to the service. | ||
35 | // For these keys the client will construct their own JWT and sign it with the | ||
36 | // private key and the service will validate the signature with the public key. | ||
37 | // These keys (as of Nov 2021) do not expire, though they can be revoked. | ||
38 | type SessionKey struct { | ||
39 | KeyId string | ||
40 | Description string | ||
41 | Revoked *time.Time | ||
42 | NotAfter *time.Time | ||
43 | NotBefore *time.Time | ||
44 | PublicKey crypto.PublicKey | ||
45 | PrivateKey *ecdsa.PrivateKey | ||
46 | } | ||
47 | |||
48 | func GenerateSessionKey(ttl time.Duration) (*SessionKey, error) { | ||
49 | pk, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) | ||
50 | if err != nil { | ||
51 | return nil, err | ||
52 | } | ||
53 | |||
54 | key := make([]byte, 8) | ||
55 | if _, err := rand.Read(key); err != nil { | ||
56 | return nil, err | ||
57 | } | ||
58 | |||
59 | now := time.Now() | ||
60 | notAfter := now.Add(ttl) | ||
61 | |||
62 | return &SessionKey{ | ||
63 | KeyId: hex.EncodeToString(key), | ||
64 | Revoked: nil, | ||
65 | NotAfter: ¬After, | ||
66 | NotBefore: &now, | ||
67 | PublicKey: pk.Public(), | ||
68 | PrivateKey: pk, | ||
69 | }, nil | ||
70 | } | ||
71 | |||
72 | // IsGarbage checks to determine if a key is garbage that should be collected. | ||
73 | // The definition of garbage is similar to the inversion of the definition of | ||
74 | // vaild but revoked keys are not considered to be garbage since they may be | ||
75 | // useful for auditing later. Also keys that are not yet valid are not garbage. | ||
76 | func (s *SessionKey) IsGarbage() bool { | ||
77 | if s.Revoked != nil { | ||
78 | return false | ||
79 | } | ||
80 | |||
81 | if s.NotBefore != nil && s.NotBefore.Before(time.Now()) { | ||
82 | return false | ||
83 | } | ||
84 | |||
85 | if s.NotAfter != nil && s.NotAfter.After(time.Now()) { | ||
86 | return true | ||
87 | } | ||
88 | |||
89 | return false | ||
90 | } | ||
91 | |||
92 | // IsValid checks the various dates in the SessionKey to verify that they are | ||
93 | // valid and in-range for use. This should be called before trusting this key | ||
94 | // for any use. | ||
95 | func (s *SessionKey) IsValid() bool { | ||
96 | if s.Revoked != nil { | ||
97 | return false | ||
98 | } | ||
99 | |||
100 | if s.NotBefore != nil && s.NotBefore.Before(time.Now()) { | ||
101 | return false | ||
102 | } | ||
103 | |||
104 | if s.NotAfter != nil && s.NotAfter.After(time.Now()) { | ||
105 | return false | ||
106 | } | ||
107 | |||
108 | return true | ||
109 | } | ||
110 | |||
111 | func (s *SessionKey) MarshalBSON() ([]byte, error) { | ||
112 | var err error | ||
113 | var pub, priv []byte | ||
114 | |||
115 | if s.PrivateKey != nil { | ||
116 | priv, err = x509.MarshalECPrivateKey(s.PrivateKey) | ||
117 | if err != nil { | ||
118 | return nil, err | ||
119 | } | ||
120 | } | ||
121 | |||
122 | // If there's a private key and a public key set then just save the private | ||
123 | // key. The private key already contains a copy of the public key. | ||
124 | if s.PublicKey != nil && s.PrivateKey == nil { | ||
125 | pub, err = x509.MarshalPKIXPublicKey(s.PublicKey) | ||
126 | if err != nil { | ||
127 | return nil, err | ||
128 | } | ||
129 | } | ||
130 | |||
131 | return bson.Marshal(struct { | ||
132 | KeyId string | ||
133 | Revoked *time.Time | ||
134 | NotAfter *time.Time | ||
135 | NotBefore *time.Time | ||
136 | PublicKey string | ||
137 | PrivateKey string | ||
138 | }{ | ||
139 | s.KeyId, | ||
140 | s.Revoked, s.NotAfter, s.NotBefore, | ||
141 | base64.StdEncoding.EncodeToString(pub), | ||
142 | base64.StdEncoding.EncodeToString(priv), | ||
143 | }) | ||
144 | } | ||
145 | |||
146 | func (s *SessionKey) UnmarshalBSON(d []byte) error { | ||
147 | v := struct { | ||
148 | KeyId string | ||
149 | Revoked *time.Time | ||
150 | NotAfter *time.Time | ||
151 | NotBefore *time.Time | ||
152 | PublicKey string | ||
153 | PrivateKey string | ||
154 | }{} | ||
155 | if err := bson.Unmarshal(d, &v); err != nil { | ||
156 | return err | ||
157 | } | ||
158 | |||
159 | s.KeyId = v.KeyId | ||
160 | s.Revoked = v.Revoked | ||
161 | s.NotAfter = v.NotAfter | ||
162 | s.NotBefore = v.NotBefore | ||
163 | |||
164 | if v.PrivateKey != "" { | ||
165 | privb, err := base64.StdEncoding.DecodeString(v.PrivateKey) | ||
166 | if err != nil { | ||
167 | return err | ||
168 | } | ||
169 | |||
170 | priv, err := x509.ParseECPrivateKey(privb) | ||
171 | if err != nil { | ||
172 | return err | ||
173 | } | ||
174 | |||
175 | s.PrivateKey = priv | ||
176 | s.PublicKey = priv.Public() | ||
177 | } | ||
178 | |||
179 | // If there was a private key then the public key was already set by | ||
180 | // decoding that private key. No need to do this a second time (also it's | ||
181 | // rather unlikely that both would be set). | ||
182 | if v.PublicKey != "" && s.PublicKey == nil { | ||
183 | pubb, err := base64.StdEncoding.DecodeString(v.PublicKey) | ||
184 | if err != nil { | ||
185 | return err | ||
186 | } | ||
187 | |||
188 | pubp, err := x509.ParsePKIXPublicKey(pubb) | ||
189 | if err != nil { | ||
190 | return err | ||
191 | } | ||
192 | |||
193 | pub, ok := pubp.(*ecdsa.PublicKey) | ||
194 | if !ok { | ||
195 | return fmt.Errorf("Failed to convert public key to *ecdsa.PublicKey") | ||
196 | } | ||
197 | |||
198 | s.PublicKey = pub | ||
199 | } | ||
200 | |||
201 | return nil | ||
202 | } | ||
diff --git a/app/models/user.go b/app/models/user.go new file mode 100644 index 0000000..0cbd92d --- /dev/null +++ b/app/models/user.go | |||
@@ -0,0 +1,99 @@ | |||
1 | package models | ||
2 | |||
3 | import ( | ||
4 | "context" | ||
5 | |||
6 | "code.crute.us/mcrute/golib/db/mongodb" | ||
7 | ) | ||
8 | |||
9 | const userCol = "users" | ||
10 | |||
11 | type UserStore interface { | ||
12 | List(context.Context) ([]*User, error) | ||
13 | Get(context.Context, string) (*User, error) // Error on not found | ||
14 | Put(context.Context, *User) error | ||
15 | Delete(context.Context, *User) error | ||
16 | } | ||
17 | |||
18 | type AuthToken struct { | ||
19 | Kind string | ||
20 | Token string | ||
21 | RefreshToken string | ||
22 | } | ||
23 | |||
24 | type User struct { | ||
25 | Username string `bson:"_id"` | ||
26 | IsAdmin bool | ||
27 | IsService bool | ||
28 | Keys map[string]*SessionKey // kid -> key | ||
29 | AuthTokens map[string]*AuthToken // kind -> token | ||
30 | } | ||
31 | |||
32 | // GCKeys garbage collects keys that are no longer valid | ||
33 | func (u *User) GCKeys() { | ||
34 | for k, v := range u.Keys { | ||
35 | if v.IsGarbage() { | ||
36 | delete(u.Keys, k) | ||
37 | } | ||
38 | } | ||
39 | } | ||
40 | |||
41 | // GetKey returns a key for a key ID. It will only return valid keys. | ||
42 | func (u *User) GetKey(kid string) *SessionKey { | ||
43 | if u.Keys != nil { | ||
44 | if k := u.Keys[kid]; k != nil && k.IsValid() { | ||
45 | return k | ||
46 | } | ||
47 | } | ||
48 | return nil | ||
49 | } | ||
50 | |||
51 | func (u *User) AddKey(k *SessionKey) { | ||
52 | if u.Keys == nil { | ||
53 | u.Keys = map[string]*SessionKey{} | ||
54 | } | ||
55 | u.Keys[k.KeyId] = k | ||
56 | } | ||
57 | |||
58 | func (u *User) AddToken(t *AuthToken) { | ||
59 | if u.AuthTokens == nil { | ||
60 | u.AuthTokens = map[string]*AuthToken{} | ||
61 | } | ||
62 | u.AuthTokens[t.Kind] = t | ||
63 | } | ||
64 | |||
65 | type MongoDbUserStore struct { | ||
66 | Db *mongodb.Mongo | ||
67 | } | ||
68 | |||
69 | func (s *MongoDbUserStore) List(ctx context.Context) ([]*User, error) { | ||
70 | var out []*User | ||
71 | if err := s.Db.FindAll(ctx, userCol, &out); err != nil { | ||
72 | return nil, err | ||
73 | } | ||
74 | return out, nil | ||
75 | } | ||
76 | |||
77 | func (s *MongoDbUserStore) Get(ctx context.Context, username string) (*User, error) { | ||
78 | var u User | ||
79 | if err := s.Db.FindOneById(ctx, userCol, username, &u); err != nil { | ||
80 | return nil, err | ||
81 | } | ||
82 | return &u, nil | ||
83 | } | ||
84 | |||
85 | func (s *MongoDbUserStore) Put(ctx context.Context, u *User) error { | ||
86 | if err := s.Db.ReplaceOneById(ctx, userCol, u.Username, u); err != nil { | ||
87 | return err | ||
88 | } | ||
89 | return nil | ||
90 | } | ||
91 | |||
92 | func (s *MongoDbUserStore) Delete(ctx context.Context, u *User) error { | ||
93 | if err := s.Db.DeleteOneById(ctx, userCol, u.Username); err != nil { | ||
94 | return err | ||
95 | } | ||
96 | return nil | ||
97 | } | ||
98 | |||
99 | var _ UserStore = (*MongoDbUserStore)(nil) | ||