Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

GitHub auth #95

Merged
merged 5 commits into from
Mar 28, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions .github/workflows/golangci-lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,6 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Make Mod Fix for Skynet Deps
run: make mod-fix
- name: golangci-lint
uses: golangci/golangci-lint-action@v2
with:
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/oci-dist-spec-content-discovery.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ jobs:
yq e -i '.environment = "ci"' config.yaml
IP=$IP yq e -i '.database.host = env(IP)' config.yaml
SKYNET_API_KEY=${{ secrets.SKYNET_API_KEY }} yq e -i '.skynet.api_key = env(SKYNET_API_KEY)' config.yaml
SKYNET_API_KEY=${{ secrets.SKYNET_API_KEY }} yq e -i '.skynet.custom_cookie = env(SKYNET_API_KEY)' config.yaml
docker build -f ./Dockerfile -t "${DISTRIBUTION_REF}" .
docker run --rm -p 5000:5000 --env="CI_SYS_ADDR=$IP:5000" -d "${DISTRIBUTION_REF}"
sleep 5
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/oci-dist-spec-content-management.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ jobs:
yq e -i '.environment = "ci"' config.yaml
IP=$IP yq e -i '.database.host = env(IP)' config.yaml
SKYNET_API_KEY=${{ secrets.SKYNET_API_KEY }} yq e -i '.skynet.api_key = env(SKYNET_API_KEY)' config.yaml
SKYNET_API_KEY=${{ secrets.SKYNET_API_KEY }} yq e -i '.skynet.custom_cookie = env(SKYNET_API_KEY)' config.yaml
docker build -f ./Dockerfile -t "${DISTRIBUTION_REF}" .
docker run --rm -p 5000:5000 --env="CI_SYS_ADDR=$IP:5000" -d "${DISTRIBUTION_REF}"
sleep 5
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/oci-dist-spec-pull.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ jobs:
yq e -i '.environment = "ci"' config.yaml
IP=$IP yq e -i '.database.host = env(IP)' config.yaml
SKYNET_API_KEY=${{ secrets.SKYNET_API_KEY }} yq e -i '.skynet.api_key = env(SKYNET_API_KEY)' config.yaml
SKYNET_API_KEY=${{ secrets.SKYNET_API_KEY }} yq e -i '.skynet.custom_cookie = env(SKYNET_API_KEY)' config.yaml
docker build -f ./Dockerfile -t "${DISTRIBUTION_REF}" .
docker run --rm -p 5000:5000 --env="CI_SYS_ADDR=$IP:5000" -d "${DISTRIBUTION_REF}"
sleep 5
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/oci-dist-spec-push.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ jobs:
yq e -i '.environment = "ci"' config.yaml
IP=$IP yq e -i '.database.host = env(IP)' config.yaml
SKYNET_API_KEY=${{ secrets.SKYNET_API_KEY }} yq e -i '.skynet.api_key = env(SKYNET_API_KEY)' config.yaml
SKYNET_API_KEY=${{ secrets.SKYNET_API_KEY }} yq e -i '.skynet.custom_cookie = env(SKYNET_API_KEY)' config.yaml
docker build -f ./Dockerfile -t "${DISTRIBUTION_REF}" .
docker run --rm -p 5000:5000 --env="CI_SYS_ADDR=$IP:5000" -d "${DISTRIBUTION_REF}"
sleep 5
Expand Down
8 changes: 6 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
mod-fix:
git clone https://github.com/jay-dee7/go-skynet.git .go-skynet
POSTGRESQL_URL='postgres://postgres:[email protected]:5432/open_registry?sslmode=disable'

migup:
migrate -database ${POSTGRESQL_URL} -path db/migrations up
migdown:
migrate -database ${POSTGRESQL_URL} -path db/migrations down

tools:
pip3 install ggshield pre-commit
Expand Down
62 changes: 54 additions & 8 deletions auth/auth.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
package auth

import (
"time"

"github.com/containerish/OpenRegistry/cache"
"github.com/containerish/OpenRegistry/config"
"github.com/containerish/OpenRegistry/store/postgres"
"github.com/containerish/OpenRegistry/telemetry"
gh "github.com/google/go-github/v42/github"
"github.com/labstack/echo/v4"
"golang.org/x/oauth2"
"golang.org/x/oauth2/github"
)

// Authentication interface defines the behaviour for container registry and general authentication for the backend
Expand All @@ -16,13 +21,8 @@ type Authentication interface {
Token(ctx echo.Context) error
JWT() echo.MiddlewareFunc
ACL() echo.MiddlewareFunc
}

type auth struct {
pgStore postgres.PersistentStore
store cache.Store
c *config.OpenRegistryConfig
logger telemetry.Logger
LoginWithGithub(ctx echo.Context) error
GithubLoginCallbackHandler(ctx echo.Context) error
}

// New is the constructor function returns an Authentication implementation
Expand All @@ -32,6 +32,52 @@ func New(
pgStore postgres.PersistentStore,
logger telemetry.Logger,
) Authentication {
a := &auth{store: s, c: c, pgStore: pgStore, logger: logger}

githubOAuth := &oauth2.Config{
ClientID: c.OAuth.Github.ClientID,
ClientSecret: c.OAuth.Github.ClientSecret,
Endpoint: github.Endpoint,
Scopes: []string{"user:email"},
}

ghClient := gh.NewClient(nil)

a := &auth{
store: s,
c: c,
pgStore: pgStore,
logger: logger,
github: githubOAuth,
ghClient: ghClient,
oauthStateStore: make(map[string]time.Time),
}

go a.StateTokenCleanup()

return a
}

type (
auth struct {
pgStore postgres.PersistentStore
store cache.Store
logger telemetry.Logger
github *oauth2.Config
ghClient *gh.Client
oauthStateStore map[string]time.Time
c *config.OpenRegistryConfig
}
)

// @TODO (jay-dee7) maybe a better way to do it?
func (a *auth) StateTokenCleanup() {
// tick every 10 minutes, delete ant oauth state tokens which are older than 10 mins
// duration = 10mins, because github short lived code is valid for 10 mins
for range time.Tick(time.Second * 10) {
for key, t := range a.oauthStateStore {
if time.Now().Unix() > t.Unix() {
delete(a.oauthStateStore, key)
}
}
}
}
26 changes: 26 additions & 0 deletions auth/basic_auth.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package auth

import (
"context"
"encoding/base64"
"fmt"
"net/http"
Expand Down Expand Up @@ -171,3 +172,28 @@ func BasicAuthWithConfig(config middleware.BasicAuthConfig) echo.MiddlewareFunc
}
}
}

// makes an http request to get user info from token, if it's valid, it's all good :)
func (a *auth) validateUserWithGithubOauthToken(ctx context.Context, token string) (bool, error) {
req, err := a.ghClient.NewRequest(http.MethodGet, "/user", nil)
if err != nil {
return false, fmt.Errorf("GH_AUTH_REQUEST_ERROR: %w", err)
}
req.Header.Set(AuthorizationHeaderKey, "token "+token)

var oauthUser types.User
resp, err := a.ghClient.Do(ctx, req, &oauthUser)
if err != nil {
return false, fmt.Errorf("GH_AUTH_ERROR: %w", err)
}

if resp.StatusCode != http.StatusOK {
return false, fmt.Errorf("UNAUTHORIZED")
}

if _, err = a.pgStore.GetUser(ctx, oauthUser.Email); err != nil {
return false, fmt.Errorf("PG_GET_USER_ERR: %w", err)
}

return true, nil
}
131 changes: 131 additions & 0 deletions auth/github.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
package auth

import (
"context"
"net/http"
"strings"
"time"

"github.com/containerish/OpenRegistry/config"
"github.com/containerish/OpenRegistry/types"
"github.com/google/uuid"
"github.com/labstack/echo/v4"
"golang.org/x/oauth2"
)

func (a *auth) LoginWithGithub(ctx echo.Context) error {
ctx.Set(types.HandlerStartTime, time.Now())
state := uuid.NewString()
a.oauthStateStore[state] = time.Now().Add(time.Minute * 10)
url := a.github.AuthCodeURL(state, oauth2.AccessTypeOffline)
a.logger.Log(ctx, nil)
return ctx.Redirect(http.StatusTemporaryRedirect, url)
}

func (a *auth) GithubLoginCallbackHandler(ctx echo.Context) error {
ctx.Set(types.HandlerStartTime, time.Now())

stateToken := ctx.FormValue("state")
_, ok := a.oauthStateStore[stateToken]
if !ok {
return ctx.JSON(http.StatusBadRequest, echo.Map{
"error": "missing or invalid state token",
})
}
// no need to compare the stateToken from QueryParam \w stateToken from a.oauthStateStore
// the key is the actual token :p
delete(a.oauthStateStore, stateToken)

code := ctx.FormValue("code")
token, err := a.github.Exchange(context.Background(), code)
if err != nil {
a.logger.Log(ctx, err)
return ctx.JSON(http.StatusBadRequest, echo.Map{
"error": err.Error(),
"code": "GITHUB_EXCHANGE_ERR",
})
}

req, err := a.ghClient.NewRequest(http.MethodGet, "/user", nil)
if err != nil {
a.logger.Log(ctx, err)
return ctx.JSON(http.StatusPreconditionFailed, echo.Map{
"error": err.Error(),
"code": "GH_CLIENT_REQ_FAILED",
})
}

req.Header.Set("Authorization", "token "+token.AccessToken)
var oauthUser types.User
_, err = a.ghClient.Do(ctx.Request().Context(), req, &oauthUser)
if err != nil {
a.logger.Log(ctx, err)
return ctx.JSON(http.StatusInternalServerError, echo.Map{
"error": err.Error(),
"code": "GH_CLIENT_REQ_EXEC_FAILED",
})
}

oauthUser.Username = oauthUser.Login
oauthUser.Id = uuid.NewString()

accessToken, refreshToken, err := a.SignOAuthToken(oauthUser, token)
if err != nil {
a.logger.Log(ctx, err)
return ctx.JSON(http.StatusInternalServerError, echo.Map{
"error": err.Error(),
"cause": "JWT_SIGNING",
})
}

secure := true
sameSite := http.SameSiteStrictMode
domain := strings.TrimPrefix(a.c.WebAppEndpoint, "https://")
if a.c.Environment == config.Local {
secure = false
sameSite = http.SameSiteLaxMode
domain = "localhost"
}

accessCookie := &http.Cookie{
Name: "access",
Value: accessToken,
Path: "/",
Domain: domain,
Expires: time.Now().Add(time.Hour),
MaxAge: AccessCookieMaxAge,
Secure: secure,
SameSite: sameSite,
HttpOnly: true,
}

refreshCookie := &http.Cookie{
Name: "refresh",
Value: refreshToken,
Path: "/",
Domain: domain,
Expires: time.Now().Add(time.Hour * 750),
MaxAge: RefreshCookieMaxAge,
Secure: secure,
SameSite: sameSite,
HttpOnly: true,
}

if err := a.pgStore.AddOAuthUser(ctx.Request().Context(), &oauthUser); err != nil {
ctx.Set(types.HttpEndpointErrorKey, err.Error())
return ctx.JSON(http.StatusInternalServerError, echo.Map{
"error": err.Error(),
"code": "GH_OAUTH_STORE_OAUTH_USER",
})
}

ctx.SetCookie(accessCookie)
ctx.SetCookie(refreshCookie)
a.logger.Log(ctx, nil)
return ctx.Redirect(http.StatusTemporaryRedirect, a.c.WebAppRedirectURL)
}

const (
AccessCookieMaxAge = int(time.Second * 3600)
RefreshCookieMaxAge = int(AccessCookieMaxAge * 3600)
)
Loading