feat: Add `ip_address` to API keys (#2580)

Fixes #2561.
This commit is contained in:
Kyle Carberry 2022-06-22 12:32:21 -05:00 committed by GitHub
parent caf9c41a9e
commit b7eeb436ad
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 144 additions and 49 deletions

View File

@ -1382,6 +1382,7 @@ func (q *fakeQuerier) InsertAPIKey(_ context.Context, arg database.InsertAPIKeyP
ID: arg.ID,
LifetimeSeconds: arg.LifetimeSeconds,
HashedSecret: arg.HashedSecret,
IPAddress: arg.IPAddress,
UserID: arg.UserID,
ExpiresAt: arg.ExpiresAt,
CreatedAt: arg.CreatedAt,
@ -1802,6 +1803,7 @@ func (q *fakeQuerier) UpdateAPIKeyByID(_ context.Context, arg database.UpdateAPI
}
apiKey.LastUsed = arg.LastUsed
apiKey.ExpiresAt = arg.ExpiresAt
apiKey.IPAddress = arg.IPAddress
apiKey.OAuthAccessToken = arg.OAuthAccessToken
apiKey.OAuthRefreshToken = arg.OAuthRefreshToken
apiKey.OAuthExpiry = arg.OAuthExpiry

View File

@ -99,7 +99,8 @@ CREATE TABLE api_keys (
oauth_refresh_token text DEFAULT ''::text NOT NULL,
oauth_id_token text DEFAULT ''::text NOT NULL,
oauth_expiry timestamp with time zone DEFAULT '0001-01-01 00:00:00+00'::timestamp with time zone NOT NULL,
lifetime_seconds bigint DEFAULT 86400 NOT NULL
lifetime_seconds bigint DEFAULT 86400 NOT NULL,
ip_address inet DEFAULT '0.0.0.0'::inet NOT NULL
);
CREATE TABLE audit_logs (

View File

@ -0,0 +1,2 @@
ALTER TABLE ONLY api_keys
DROP COLUMN IF EXISTS ip_address;

View File

@ -0,0 +1,2 @@
ALTER TABLE ONLY api_keys
ADD COLUMN IF NOT EXISTS ip_address inet NOT NULL DEFAULT '0.0.0.0';

View File

@ -311,19 +311,20 @@ func (e *WorkspaceTransition) Scan(src interface{}) error {
}
type APIKey struct {
ID string `db:"id" json:"id"`
HashedSecret []byte `db:"hashed_secret" json:"hashed_secret"`
UserID uuid.UUID `db:"user_id" json:"user_id"`
LastUsed time.Time `db:"last_used" json:"last_used"`
ExpiresAt time.Time `db:"expires_at" json:"expires_at"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
LoginType LoginType `db:"login_type" json:"login_type"`
OAuthAccessToken string `db:"oauth_access_token" json:"oauth_access_token"`
OAuthRefreshToken string `db:"oauth_refresh_token" json:"oauth_refresh_token"`
OAuthIDToken string `db:"oauth_id_token" json:"oauth_id_token"`
OAuthExpiry time.Time `db:"oauth_expiry" json:"oauth_expiry"`
LifetimeSeconds int64 `db:"lifetime_seconds" json:"lifetime_seconds"`
ID string `db:"id" json:"id"`
HashedSecret []byte `db:"hashed_secret" json:"hashed_secret"`
UserID uuid.UUID `db:"user_id" json:"user_id"`
LastUsed time.Time `db:"last_used" json:"last_used"`
ExpiresAt time.Time `db:"expires_at" json:"expires_at"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
LoginType LoginType `db:"login_type" json:"login_type"`
OAuthAccessToken string `db:"oauth_access_token" json:"oauth_access_token"`
OAuthRefreshToken string `db:"oauth_refresh_token" json:"oauth_refresh_token"`
OAuthIDToken string `db:"oauth_id_token" json:"oauth_id_token"`
OAuthExpiry time.Time `db:"oauth_expiry" json:"oauth_expiry"`
LifetimeSeconds int64 `db:"lifetime_seconds" json:"lifetime_seconds"`
IPAddress pqtype.Inet `db:"ip_address" json:"ip_address"`
}
type AuditLog struct {

View File

@ -30,7 +30,7 @@ func (q *sqlQuerier) DeleteAPIKeyByID(ctx context.Context, id string) error {
const getAPIKeyByID = `-- name: GetAPIKeyByID :one
SELECT
id, hashed_secret, user_id, last_used, expires_at, created_at, updated_at, login_type, oauth_access_token, oauth_refresh_token, oauth_id_token, oauth_expiry, lifetime_seconds
id, hashed_secret, user_id, last_used, expires_at, created_at, updated_at, login_type, oauth_access_token, oauth_refresh_token, oauth_id_token, oauth_expiry, lifetime_seconds, ip_address
FROM
api_keys
WHERE
@ -56,12 +56,13 @@ func (q *sqlQuerier) GetAPIKeyByID(ctx context.Context, id string) (APIKey, erro
&i.OAuthIDToken,
&i.OAuthExpiry,
&i.LifetimeSeconds,
&i.IPAddress,
)
return i, err
}
const getAPIKeysLastUsedAfter = `-- name: GetAPIKeysLastUsedAfter :many
SELECT id, hashed_secret, user_id, last_used, expires_at, created_at, updated_at, login_type, oauth_access_token, oauth_refresh_token, oauth_id_token, oauth_expiry, lifetime_seconds FROM api_keys WHERE last_used > $1
SELECT id, hashed_secret, user_id, last_used, expires_at, created_at, updated_at, login_type, oauth_access_token, oauth_refresh_token, oauth_id_token, oauth_expiry, lifetime_seconds, ip_address FROM api_keys WHERE last_used > $1
`
func (q *sqlQuerier) GetAPIKeysLastUsedAfter(ctx context.Context, lastUsed time.Time) ([]APIKey, error) {
@ -87,6 +88,7 @@ func (q *sqlQuerier) GetAPIKeysLastUsedAfter(ctx context.Context, lastUsed time.
&i.OAuthIDToken,
&i.OAuthExpiry,
&i.LifetimeSeconds,
&i.IPAddress,
); err != nil {
return nil, err
}
@ -107,6 +109,7 @@ INSERT INTO
id,
lifetime_seconds,
hashed_secret,
ip_address,
user_id,
last_used,
expires_at,
@ -125,23 +128,24 @@ VALUES
WHEN 0 THEN 86400
ELSE $2::bigint
END
, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) RETURNING id, hashed_secret, user_id, last_used, expires_at, created_at, updated_at, login_type, oauth_access_token, oauth_refresh_token, oauth_id_token, oauth_expiry, lifetime_seconds
, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) RETURNING id, hashed_secret, user_id, last_used, expires_at, created_at, updated_at, login_type, oauth_access_token, oauth_refresh_token, oauth_id_token, oauth_expiry, lifetime_seconds, ip_address
`
type InsertAPIKeyParams struct {
ID string `db:"id" json:"id"`
LifetimeSeconds int64 `db:"lifetime_seconds" json:"lifetime_seconds"`
HashedSecret []byte `db:"hashed_secret" json:"hashed_secret"`
UserID uuid.UUID `db:"user_id" json:"user_id"`
LastUsed time.Time `db:"last_used" json:"last_used"`
ExpiresAt time.Time `db:"expires_at" json:"expires_at"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
LoginType LoginType `db:"login_type" json:"login_type"`
OAuthAccessToken string `db:"oauth_access_token" json:"oauth_access_token"`
OAuthRefreshToken string `db:"oauth_refresh_token" json:"oauth_refresh_token"`
OAuthIDToken string `db:"oauth_id_token" json:"oauth_id_token"`
OAuthExpiry time.Time `db:"oauth_expiry" json:"oauth_expiry"`
ID string `db:"id" json:"id"`
LifetimeSeconds int64 `db:"lifetime_seconds" json:"lifetime_seconds"`
HashedSecret []byte `db:"hashed_secret" json:"hashed_secret"`
IPAddress pqtype.Inet `db:"ip_address" json:"ip_address"`
UserID uuid.UUID `db:"user_id" json:"user_id"`
LastUsed time.Time `db:"last_used" json:"last_used"`
ExpiresAt time.Time `db:"expires_at" json:"expires_at"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
LoginType LoginType `db:"login_type" json:"login_type"`
OAuthAccessToken string `db:"oauth_access_token" json:"oauth_access_token"`
OAuthRefreshToken string `db:"oauth_refresh_token" json:"oauth_refresh_token"`
OAuthIDToken string `db:"oauth_id_token" json:"oauth_id_token"`
OAuthExpiry time.Time `db:"oauth_expiry" json:"oauth_expiry"`
}
func (q *sqlQuerier) InsertAPIKey(ctx context.Context, arg InsertAPIKeyParams) (APIKey, error) {
@ -149,6 +153,7 @@ func (q *sqlQuerier) InsertAPIKey(ctx context.Context, arg InsertAPIKeyParams) (
arg.ID,
arg.LifetimeSeconds,
arg.HashedSecret,
arg.IPAddress,
arg.UserID,
arg.LastUsed,
arg.ExpiresAt,
@ -175,6 +180,7 @@ func (q *sqlQuerier) InsertAPIKey(ctx context.Context, arg InsertAPIKeyParams) (
&i.OAuthIDToken,
&i.OAuthExpiry,
&i.LifetimeSeconds,
&i.IPAddress,
)
return i, err
}
@ -185,20 +191,22 @@ UPDATE
SET
last_used = $2,
expires_at = $3,
oauth_access_token = $4,
oauth_refresh_token = $5,
oauth_expiry = $6
ip_address = $4,
oauth_access_token = $5,
oauth_refresh_token = $6,
oauth_expiry = $7
WHERE
id = $1
`
type UpdateAPIKeyByIDParams struct {
ID string `db:"id" json:"id"`
LastUsed time.Time `db:"last_used" json:"last_used"`
ExpiresAt time.Time `db:"expires_at" json:"expires_at"`
OAuthAccessToken string `db:"oauth_access_token" json:"oauth_access_token"`
OAuthRefreshToken string `db:"oauth_refresh_token" json:"oauth_refresh_token"`
OAuthExpiry time.Time `db:"oauth_expiry" json:"oauth_expiry"`
ID string `db:"id" json:"id"`
LastUsed time.Time `db:"last_used" json:"last_used"`
ExpiresAt time.Time `db:"expires_at" json:"expires_at"`
IPAddress pqtype.Inet `db:"ip_address" json:"ip_address"`
OAuthAccessToken string `db:"oauth_access_token" json:"oauth_access_token"`
OAuthRefreshToken string `db:"oauth_refresh_token" json:"oauth_refresh_token"`
OAuthExpiry time.Time `db:"oauth_expiry" json:"oauth_expiry"`
}
func (q *sqlQuerier) UpdateAPIKeyByID(ctx context.Context, arg UpdateAPIKeyByIDParams) error {
@ -206,6 +214,7 @@ func (q *sqlQuerier) UpdateAPIKeyByID(ctx context.Context, arg UpdateAPIKeyByIDP
arg.ID,
arg.LastUsed,
arg.ExpiresAt,
arg.IPAddress,
arg.OAuthAccessToken,
arg.OAuthRefreshToken,
arg.OAuthExpiry,

View File

@ -17,6 +17,7 @@ INSERT INTO
id,
lifetime_seconds,
hashed_secret,
ip_address,
user_id,
last_used,
expires_at,
@ -35,7 +36,7 @@ VALUES
WHEN 0 THEN 86400
ELSE @lifetime_seconds::bigint
END
, @hashed_secret, @user_id, @last_used, @expires_at, @created_at, @updated_at, @login_type, @oauth_access_token, @oauth_refresh_token, @oauth_id_token, @oauth_expiry) RETURNING *;
, @hashed_secret, @ip_address, @user_id, @last_used, @expires_at, @created_at, @updated_at, @login_type, @oauth_access_token, @oauth_refresh_token, @oauth_id_token, @oauth_expiry) RETURNING *;
-- name: UpdateAPIKeyByID :exec
UPDATE
@ -43,9 +44,10 @@ UPDATE
SET
last_used = $2,
expires_at = $3,
oauth_access_token = $4,
oauth_refresh_token = $5,
oauth_expiry = $6
ip_address = $4,
oauth_access_token = $5,
oauth_refresh_token = $6,
oauth_expiry = $7
WHERE
id = $1;

View File

@ -29,3 +29,4 @@ rename:
userstatus: UserStatus
gitsshkey: GitSSHKey
rbac_roles: RBACRoles
ip_address: IPAddress

View File

@ -7,12 +7,15 @@ import (
"database/sql"
"errors"
"fmt"
"net"
"net/http"
"strings"
"time"
"golang.org/x/oauth2"
"github.com/tabbed/pqtype"
"github.com/coder/coder/coderd/database"
"github.com/coder/coder/coderd/httpapi"
)
@ -164,6 +167,17 @@ func ExtractAPIKey(db database.Store, oauth *OAuth2Configs) func(http.Handler) h
// Only update LastUsed once an hour to prevent database spam.
if now.Sub(key.LastUsed) > time.Hour {
key.LastUsed = now
remoteIP := net.ParseIP(r.RemoteAddr)
if remoteIP == nil {
remoteIP = net.IPv4(0, 0, 0, 0)
}
key.IPAddress = pqtype.Inet{
IPNet: net.IPNet{
IP: remoteIP,
Mask: remoteIP.DefaultMask(),
},
Valid: true,
}
changed = true
}
// Only update the ExpiresAt once an hour to prevent database spam.
@ -178,6 +192,7 @@ func ExtractAPIKey(db database.Store, oauth *OAuth2Configs) func(http.Handler) h
ID: key.ID,
LastUsed: key.LastUsed,
ExpiresAt: key.ExpiresAt,
IPAddress: key.IPAddress,
OAuthAccessToken: key.OAuthAccessToken,
OAuthRefreshToken: key.OAuthRefreshToken,
OAuthExpiry: key.OAuthExpiry,

View File

@ -402,6 +402,41 @@ func TestAPIKey(t *testing.T) {
require.Equal(t, token.Expiry, gotAPIKey.ExpiresAt)
require.Equal(t, token.AccessToken, gotAPIKey.OAuthAccessToken)
})
t.Run("RemoteIPUpdates", func(t *testing.T) {
t.Parallel()
var (
db = databasefake.New()
id, secret = randomAPIKeyParts()
hashed = sha256.Sum256([]byte(secret))
r = httptest.NewRequest("GET", "/", nil)
rw = httptest.NewRecorder()
user = createUser(r.Context(), t, db)
)
r.RemoteAddr = "1.1.1.1"
r.AddCookie(&http.Cookie{
Name: httpmw.SessionTokenKey,
Value: fmt.Sprintf("%s-%s", id, secret),
})
sentAPIKey, err := db.InsertAPIKey(r.Context(), database.InsertAPIKeyParams{
ID: id,
HashedSecret: hashed[:],
LastUsed: database.Now().AddDate(0, 0, -1),
ExpiresAt: database.Now().AddDate(0, 0, 1),
UserID: user.ID,
})
require.NoError(t, err)
httpmw.ExtractAPIKey(db, nil)(successHandler).ServeHTTP(rw, r)
res := rw.Result()
defer res.Body.Close()
require.Equal(t, http.StatusOK, res.StatusCode)
gotAPIKey, err := db.GetAPIKeyByID(r.Context(), id)
require.NoError(t, err)
require.NotEqual(t, sentAPIKey.IPAddress, gotAPIKey.IPAddress)
})
}
func createUser(ctx context.Context, t *testing.T, db database.Store) database.User {

View File

@ -7,6 +7,7 @@ import (
"encoding/json"
"errors"
"fmt"
"net"
"net/http"
"net/url"
"runtime"
@ -428,13 +429,17 @@ func (r *remoteReporter) createSnapshot() (*Snapshot, error) {
// ConvertAPIKey anonymizes an API key.
func ConvertAPIKey(apiKey database.APIKey) APIKey {
return APIKey{
a := APIKey{
ID: apiKey.ID,
UserID: apiKey.UserID,
CreatedAt: apiKey.CreatedAt,
LastUsed: apiKey.LastUsed,
LoginType: apiKey.LoginType,
}
if apiKey.IPAddress.Valid {
a.IPAddress = apiKey.IPAddress.IPNet.IP
}
return a
}
// ConvertWorkspace anonymizes a workspace.
@ -616,6 +621,7 @@ type APIKey struct {
CreatedAt time.Time `json:"created_at"`
LastUsed time.Time `json:"last_used"`
LoginType database.LoginType `json:"login_type"`
IPAddress net.IP `json:"ip_address"`
}
type User struct {

View File

@ -6,6 +6,7 @@ import (
"database/sql"
"errors"
"fmt"
"net"
"net/http"
"strings"
"time"
@ -13,6 +14,7 @@ import (
"github.com/go-chi/chi/v5"
"github.com/go-chi/render"
"github.com/google/uuid"
"github.com/tabbed/pqtype"
"golang.org/x/xerrors"
"github.com/coder/coder/coderd/database"
@ -798,10 +800,21 @@ func (api *API) createAPIKey(rw http.ResponseWriter, r *http.Request, params dat
}
}
_, err = api.Database.InsertAPIKey(r.Context(), database.InsertAPIKeyParams{
ip := net.ParseIP(r.RemoteAddr)
if ip == nil {
ip = net.IPv4(0, 0, 0, 0)
}
key, err := api.Database.InsertAPIKey(r.Context(), database.InsertAPIKeyParams{
ID: keyID,
UserID: params.UserID,
LifetimeSeconds: params.LifetimeSeconds,
IPAddress: pqtype.Inet{
IPNet: net.IPNet{
IP: ip,
Mask: ip.DefaultMask(),
},
Valid: true,
},
// Make sure in UTC time for common time zone
ExpiresAt: params.ExpiresAt.UTC(),
CreatedAt: database.Now(),
@ -821,6 +834,10 @@ func (api *API) createAPIKey(rw http.ResponseWriter, r *http.Request, params dat
return "", false
}
api.Telemetry.Report(&telemetry.Snapshot{
APIKeys: []telemetry.APIKey{telemetry.ConvertAPIKey(key)},
})
// This format is consumed by the APIKey middleware.
sessionToken := fmt.Sprintf("%s-%s", keyID, keySecret)
http.SetCookie(rw, &http.Cookie{

View File

@ -175,8 +175,9 @@ func TestPostLogin(t *testing.T) {
require.NoError(t, err, "fetch api key")
err = api.Database.UpdateAPIKeyByID(ctx, database.UpdateAPIKeyByIDParams{
ID: apiKey.ID,
LastUsed: apiKey.LastUsed,
ID: apiKey.ID,
LastUsed: apiKey.LastUsed,
IPAddress: apiKey.IPAddress,
// This should cause a refresh
ExpiresAt: apiKey.ExpiresAt.Add(time.Hour * -2),
OAuthAccessToken: apiKey.OAuthAccessToken,
@ -207,8 +208,9 @@ func TestPostLogin(t *testing.T) {
require.NoError(t, err, "fetch login key")
err = api.Database.UpdateAPIKeyByID(ctx, database.UpdateAPIKeyByIDParams{
ID: apiKey.ID,
LastUsed: apiKey.LastUsed,
ID: apiKey.ID,
LastUsed: apiKey.LastUsed,
IPAddress: apiKey.IPAddress,
// This should cause a refresh
ExpiresAt: apiKey.ExpiresAt.Add(time.Hour * -2),
OAuthAccessToken: apiKey.OAuthAccessToken,