mirror of https://github.com/coder/coder.git
432 lines
13 KiB
Go
432 lines
13 KiB
Go
package httpmw
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"crypto/subtle"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/tabbed/pqtype"
|
|
"golang.org/x/oauth2"
|
|
"golang.org/x/xerrors"
|
|
|
|
"github.com/coder/coder/coderd/database"
|
|
"github.com/coder/coder/coderd/httpapi"
|
|
"github.com/coder/coder/coderd/rbac"
|
|
"github.com/coder/coder/codersdk"
|
|
)
|
|
|
|
// The special cookie name used for subdomain-based application proxying.
|
|
// TODO: this will make dogfooding harder so come up with a more unique
|
|
// solution
|
|
//
|
|
//nolint:gosec
|
|
const DevURLSessionTokenCookie = "coder_devurl_session_token"
|
|
|
|
type apiKeyContextKey struct{}
|
|
|
|
// APIKeyOptional may return an API key from the ExtractAPIKey handler.
|
|
func APIKeyOptional(r *http.Request) (database.APIKey, bool) {
|
|
key, ok := r.Context().Value(apiKeyContextKey{}).(database.APIKey)
|
|
return key, ok
|
|
}
|
|
|
|
// APIKey returns the API key from the ExtractAPIKey handler.
|
|
func APIKey(r *http.Request) database.APIKey {
|
|
key, ok := APIKeyOptional(r)
|
|
if !ok {
|
|
panic("developer error: ExtractAPIKey middleware not provided")
|
|
}
|
|
return key
|
|
}
|
|
|
|
// User roles are the 'subject' field of Authorize()
|
|
type userAuthKey struct{}
|
|
|
|
type Authorization struct {
|
|
Actor rbac.Subject
|
|
// Username is required for logging and human friendly related
|
|
// identification.
|
|
Username string
|
|
}
|
|
|
|
// UserAuthorizationOptional may return the roles and scope used for
|
|
// authorization. Depends on the ExtractAPIKey handler.
|
|
func UserAuthorizationOptional(r *http.Request) (Authorization, bool) {
|
|
auth, ok := r.Context().Value(userAuthKey{}).(Authorization)
|
|
return auth, ok
|
|
}
|
|
|
|
// UserAuthorization returns the roles and scope used for authorization. Depends
|
|
// on the ExtractAPIKey handler.
|
|
func UserAuthorization(r *http.Request) Authorization {
|
|
auth, ok := UserAuthorizationOptional(r)
|
|
if !ok {
|
|
panic("developer error: ExtractAPIKey middleware not provided")
|
|
}
|
|
return auth
|
|
}
|
|
|
|
// OAuth2Configs is a collection of configurations for OAuth-based authentication.
|
|
// This should be extended to support other authentication types in the future.
|
|
type OAuth2Configs struct {
|
|
Github OAuth2Config
|
|
OIDC OAuth2Config
|
|
}
|
|
|
|
const (
|
|
SignedOutErrorMessage = "You are signed out or your session has expired. Please sign in again to continue."
|
|
internalErrorMessage = "An internal error occurred. Please try again or contact the system administrator."
|
|
)
|
|
|
|
type ExtractAPIKeyConfig struct {
|
|
DB database.Store
|
|
OAuth2Configs *OAuth2Configs
|
|
RedirectToLogin bool
|
|
|
|
// Optional governs whether the API key is optional. Use this if you want to
|
|
// allow unauthenticated requests.
|
|
//
|
|
// If true and no session token is provided, nothing will be written to the
|
|
// request context. Use the APIKeyOptional and UserAuthorizationOptional
|
|
// functions to retrieve the API key and authorization instead of the
|
|
// regular ones.
|
|
//
|
|
// If true and the API key is invalid (i.e. deleted, expired), the cookie
|
|
// will be deleted and the request will continue. If the request is not a
|
|
// cookie-based request, the request will be rejected with a 401.
|
|
Optional bool
|
|
}
|
|
|
|
// ExtractAPIKey requires authentication using a valid API key. It handles
|
|
// extending an API key if it comes close to expiry, updating the last used time
|
|
// in the database.
|
|
// nolint:revive
|
|
func ExtractAPIKey(cfg ExtractAPIKeyConfig) func(http.Handler) http.Handler {
|
|
return func(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
// Write wraps writing a response to redirect if the handler
|
|
// specified it should. This redirect is used for user-facing pages
|
|
// like workspace applications.
|
|
write := func(code int, response codersdk.Response) {
|
|
if cfg.RedirectToLogin {
|
|
RedirectToLogin(rw, r, response.Message)
|
|
return
|
|
}
|
|
|
|
httpapi.Write(ctx, rw, code, response)
|
|
}
|
|
|
|
// optionalWrite wraps write, but will pass the request on to the
|
|
// next handler if the configuration says the API key is optional.
|
|
//
|
|
// It should be used when the API key is not provided or is invalid,
|
|
// but not when there are other errors.
|
|
optionalWrite := func(code int, response codersdk.Response) {
|
|
if cfg.Optional {
|
|
next.ServeHTTP(rw, r)
|
|
return
|
|
}
|
|
|
|
write(code, response)
|
|
}
|
|
|
|
token := apiTokenFromRequest(r)
|
|
if token == "" {
|
|
optionalWrite(http.StatusUnauthorized, codersdk.Response{
|
|
Message: SignedOutErrorMessage,
|
|
Detail: fmt.Sprintf("Cookie %q or query parameter must be provided.", codersdk.SessionTokenCookie),
|
|
})
|
|
return
|
|
}
|
|
|
|
keyID, keySecret, err := SplitAPIToken(token)
|
|
if err != nil {
|
|
optionalWrite(http.StatusUnauthorized, codersdk.Response{
|
|
Message: SignedOutErrorMessage,
|
|
Detail: "Invalid API key format: " + err.Error(),
|
|
})
|
|
return
|
|
}
|
|
|
|
key, err := cfg.DB.GetAPIKeyByID(r.Context(), keyID)
|
|
if err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
optionalWrite(http.StatusUnauthorized, codersdk.Response{
|
|
Message: SignedOutErrorMessage,
|
|
Detail: "API key is invalid.",
|
|
})
|
|
return
|
|
}
|
|
write(http.StatusInternalServerError, codersdk.Response{
|
|
Message: internalErrorMessage,
|
|
Detail: fmt.Sprintf("Internal error fetching API key by id. %s", err.Error()),
|
|
})
|
|
return
|
|
}
|
|
|
|
// Checking to see if the secret is valid.
|
|
hashedSecret := sha256.Sum256([]byte(keySecret))
|
|
if subtle.ConstantTimeCompare(key.HashedSecret, hashedSecret[:]) != 1 {
|
|
optionalWrite(http.StatusUnauthorized, codersdk.Response{
|
|
Message: SignedOutErrorMessage,
|
|
Detail: "API key secret is invalid.",
|
|
})
|
|
return
|
|
}
|
|
|
|
var (
|
|
link database.UserLink
|
|
now = database.Now()
|
|
// Tracks if the API key has properties updated
|
|
changed = false
|
|
)
|
|
if key.LoginType == database.LoginTypeGithub || key.LoginType == database.LoginTypeOIDC {
|
|
link, err = cfg.DB.GetUserLinkByUserIDLoginType(r.Context(), database.GetUserLinkByUserIDLoginTypeParams{
|
|
UserID: key.UserID,
|
|
LoginType: key.LoginType,
|
|
})
|
|
if err != nil {
|
|
write(http.StatusInternalServerError, codersdk.Response{
|
|
Message: "A database error occurred",
|
|
Detail: fmt.Sprintf("get user link by user ID and login type: %s", err.Error()),
|
|
})
|
|
return
|
|
}
|
|
// Check if the OAuth token is expired
|
|
if link.OAuthExpiry.Before(now) && !link.OAuthExpiry.IsZero() && link.OAuthRefreshToken != "" {
|
|
var oauthConfig OAuth2Config
|
|
switch key.LoginType {
|
|
case database.LoginTypeGithub:
|
|
oauthConfig = cfg.OAuth2Configs.Github
|
|
case database.LoginTypeOIDC:
|
|
oauthConfig = cfg.OAuth2Configs.OIDC
|
|
default:
|
|
write(http.StatusInternalServerError, codersdk.Response{
|
|
Message: internalErrorMessage,
|
|
Detail: fmt.Sprintf("Unexpected authentication type %q.", key.LoginType),
|
|
})
|
|
return
|
|
}
|
|
// If it is, let's refresh it from the provided config
|
|
token, err := oauthConfig.TokenSource(r.Context(), &oauth2.Token{
|
|
AccessToken: link.OAuthAccessToken,
|
|
RefreshToken: link.OAuthRefreshToken,
|
|
Expiry: link.OAuthExpiry,
|
|
}).Token()
|
|
if err != nil {
|
|
write(http.StatusUnauthorized, codersdk.Response{
|
|
Message: "Could not refresh expired Oauth token.",
|
|
Detail: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
link.OAuthAccessToken = token.AccessToken
|
|
link.OAuthRefreshToken = token.RefreshToken
|
|
link.OAuthExpiry = token.Expiry
|
|
key.ExpiresAt = token.Expiry
|
|
changed = true
|
|
}
|
|
}
|
|
|
|
// Checking if the key is expired.
|
|
if key.ExpiresAt.Before(now) {
|
|
optionalWrite(http.StatusUnauthorized, codersdk.Response{
|
|
Message: SignedOutErrorMessage,
|
|
Detail: fmt.Sprintf("API key expired at %q.", key.ExpiresAt.String()),
|
|
})
|
|
return
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
bitlen := len(remoteIP) * 8
|
|
key.IPAddress = pqtype.Inet{
|
|
IPNet: net.IPNet{
|
|
IP: remoteIP,
|
|
Mask: net.CIDRMask(bitlen, bitlen),
|
|
},
|
|
Valid: true,
|
|
}
|
|
changed = true
|
|
}
|
|
// Only update the ExpiresAt once an hour to prevent database spam.
|
|
// We extend the ExpiresAt to reduce re-authentication.
|
|
apiKeyLifetime := time.Duration(key.LifetimeSeconds) * time.Second
|
|
if key.ExpiresAt.Sub(now) <= apiKeyLifetime-time.Hour {
|
|
key.ExpiresAt = now.Add(apiKeyLifetime)
|
|
changed = true
|
|
}
|
|
if changed {
|
|
err := cfg.DB.UpdateAPIKeyByID(r.Context(), database.UpdateAPIKeyByIDParams{
|
|
ID: key.ID,
|
|
LastUsed: key.LastUsed,
|
|
ExpiresAt: key.ExpiresAt,
|
|
IPAddress: key.IPAddress,
|
|
})
|
|
if err != nil {
|
|
write(http.StatusInternalServerError, codersdk.Response{
|
|
Message: internalErrorMessage,
|
|
Detail: fmt.Sprintf("API key couldn't update: %s.", err.Error()),
|
|
})
|
|
return
|
|
}
|
|
// If the API Key is associated with a user_link (e.g. Github/OIDC)
|
|
// then we want to update the relevant oauth fields.
|
|
if link.UserID != uuid.Nil {
|
|
link, err = cfg.DB.UpdateUserLink(r.Context(), database.UpdateUserLinkParams{
|
|
UserID: link.UserID,
|
|
LoginType: link.LoginType,
|
|
OAuthAccessToken: link.OAuthAccessToken,
|
|
OAuthRefreshToken: link.OAuthRefreshToken,
|
|
OAuthExpiry: link.OAuthExpiry,
|
|
})
|
|
if err != nil {
|
|
write(http.StatusInternalServerError, codersdk.Response{
|
|
Message: internalErrorMessage,
|
|
Detail: fmt.Sprintf("update user_link: %s.", err.Error()),
|
|
})
|
|
return
|
|
}
|
|
}
|
|
|
|
// We only want to update this occasionally to reduce DB write
|
|
// load. We update alongside the UserLink and APIKey since it's
|
|
// easier on the DB to colocate writes.
|
|
_, err = cfg.DB.UpdateUserLastSeenAt(ctx, database.UpdateUserLastSeenAtParams{
|
|
ID: key.UserID,
|
|
LastSeenAt: database.Now(),
|
|
UpdatedAt: database.Now(),
|
|
})
|
|
if err != nil {
|
|
write(http.StatusInternalServerError, codersdk.Response{
|
|
Message: internalErrorMessage,
|
|
Detail: fmt.Sprintf("update user last_seen_at: %s", err.Error()),
|
|
})
|
|
return
|
|
}
|
|
}
|
|
|
|
// If the key is valid, we also fetch the user roles and status.
|
|
// The roles are used for RBAC authorize checks, and the status
|
|
// is to block 'suspended' users from accessing the platform.
|
|
roles, err := cfg.DB.GetAuthorizationUserRoles(r.Context(), key.UserID)
|
|
if err != nil {
|
|
write(http.StatusUnauthorized, codersdk.Response{
|
|
Message: internalErrorMessage,
|
|
Detail: fmt.Sprintf("Internal error fetching user's roles. %s", err.Error()),
|
|
})
|
|
return
|
|
}
|
|
|
|
if roles.Status != database.UserStatusActive {
|
|
write(http.StatusUnauthorized, codersdk.Response{
|
|
Message: fmt.Sprintf("User is not active (status = %q). Contact an admin to reactivate your account.", roles.Status),
|
|
})
|
|
return
|
|
}
|
|
|
|
ctx = context.WithValue(ctx, apiKeyContextKey{}, key)
|
|
ctx = context.WithValue(ctx, userAuthKey{}, Authorization{
|
|
Username: roles.Username,
|
|
Actor: rbac.Subject{
|
|
ID: key.UserID.String(),
|
|
Roles: rbac.RoleNames(roles.Roles),
|
|
Groups: roles.Groups,
|
|
Scope: rbac.ScopeName(key.Scope),
|
|
},
|
|
})
|
|
|
|
next.ServeHTTP(rw, r.WithContext(ctx))
|
|
})
|
|
}
|
|
}
|
|
|
|
// apiTokenFromRequest returns the api token from the request.
|
|
// Find the session token from:
|
|
// 1: The cookie
|
|
// 1: The devurl cookie
|
|
// 3: The old cookie
|
|
// 4. The coder_session_token query parameter
|
|
// 5. The custom auth header
|
|
func apiTokenFromRequest(r *http.Request) string {
|
|
cookie, err := r.Cookie(codersdk.SessionTokenCookie)
|
|
if err == nil && cookie.Value != "" {
|
|
return cookie.Value
|
|
}
|
|
|
|
urlValue := r.URL.Query().Get(codersdk.SessionTokenCookie)
|
|
if urlValue != "" {
|
|
return urlValue
|
|
}
|
|
|
|
headerValue := r.Header.Get(codersdk.SessionTokenHeader)
|
|
if headerValue != "" {
|
|
return headerValue
|
|
}
|
|
|
|
cookie, err = r.Cookie(DevURLSessionTokenCookie)
|
|
if err == nil && cookie.Value != "" {
|
|
return cookie.Value
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
// SplitAPIToken verifies the format of an API key and returns the split ID and
|
|
// secret.
|
|
//
|
|
// APIKeys are formatted: ${ID}-${SECRET}
|
|
func SplitAPIToken(token string) (id string, secret string, err error) {
|
|
parts := strings.Split(token, "-")
|
|
if len(parts) != 2 {
|
|
return "", "", xerrors.Errorf("incorrect amount of API key parts, expected 2 got %d", len(parts))
|
|
}
|
|
|
|
// Ensure key lengths are valid.
|
|
keyID := parts[0]
|
|
keySecret := parts[1]
|
|
if len(keyID) != 10 {
|
|
return "", "", xerrors.Errorf("invalid API key ID length, expected 10 got %d", len(keyID))
|
|
}
|
|
if len(keySecret) != 22 {
|
|
return "", "", xerrors.Errorf("invalid API key secret length, expected 22 got %d", len(keySecret))
|
|
}
|
|
|
|
return keyID, keySecret, nil
|
|
}
|
|
|
|
// RedirectToLogin redirects the user to the login page with the `message` and
|
|
// `redirect` query parameters set.
|
|
func RedirectToLogin(rw http.ResponseWriter, r *http.Request, message string) {
|
|
path := r.URL.Path
|
|
if r.URL.RawQuery != "" {
|
|
path += "?" + r.URL.RawQuery
|
|
}
|
|
|
|
q := url.Values{}
|
|
q.Add("message", message)
|
|
q.Add("redirect", path)
|
|
|
|
u := &url.URL{
|
|
Path: "/login",
|
|
RawQuery: q.Encode(),
|
|
}
|
|
|
|
http.Redirect(rw, r, u.String(), http.StatusTemporaryRedirect)
|
|
}
|