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

Sectigo Staging Service #917

Merged
merged 8 commits into from
Nov 30, 2022
Merged
Show file tree
Hide file tree
Changes from 2 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
303 changes: 188 additions & 115 deletions cmd/sectigo/main.go

Large diffs are not rendered by default.

7 changes: 7 additions & 0 deletions pkg/sectigo/config.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package sectigo

import (
"errors"
"fmt"
"strings"
)
Expand All @@ -9,6 +10,7 @@ type Config struct {
Username string `envconfig:"SECTIGO_USERNAME" required:"false"`
Password string `envconfig:"SECTIGO_PASSWORD" required:"false"`
Profile string `envconfig:"SECTIGO_PROFILE" default:"CipherTrace EE"`
Endpoint string `envconfig:"SECTIGO_ENDPOINT" required:"false"`
Testing bool `envconfig:"SECTIGO_TESTING" default:"false"`
}

Expand All @@ -17,5 +19,10 @@ func (c Config) Validate() error {
if _, ok := Profiles[c.Profile]; !ok {
return fmt.Errorf("%q is not a valid Sectigo profile name, specify one of %s", c.Profile, strings.Join(AllProfiles(), ", "))
}

// Can only specify an alternative Sectigo endpoint if in testing mode.
if c.Endpoint != "" && !c.Testing {
return errors.New("invalid configuration: cannot specify endpoint if not in testing mode")
}
return nil
}
2 changes: 2 additions & 0 deletions pkg/sectigo/creds.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ const (
UsernameEnv = "SECTIGO_USERNAME"
PasswordEnv = "SECTIGO_PASSWORD"
ProfileEnv = "SECTIGO_PROFILE"
EndpointEnv = "SECTIGO_ENDPOINT"
TestingEnv = "SECTIGO_TESTING"
)

// Cache directory configuration
Expand Down
9 changes: 9 additions & 0 deletions pkg/sectigo/sectigo.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"mime/multipart"
"net/http"
"net/textproto"
"net/url"
"os"
"path/filepath"
"sort"
Expand Down Expand Up @@ -124,6 +125,14 @@ func New(conf Config) (client *Sectigo, err error) {
if conf.Password == "" {
conf.Password = MockPassword
}

if conf.Endpoint != "" {
var u *url.URL
if u, err = url.Parse(conf.Endpoint); err != nil {
return nil, fmt.Errorf("could not parse sectigo endpoint: %w", err)
}
SetBaseURL(u)
}
bbengfort marked this conversation as resolved.
Show resolved Hide resolved
}

if err = client.creds.Load(conf.Username, conf.Password); err != nil {
Expand Down
214 changes: 214 additions & 0 deletions pkg/sectigo/server/auth.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
package server

import (
"errors"
"fmt"
"io"
"net/http"
"strings"
"time"

"github.com/gin-gonic/gin"
jwt "github.com/golang-jwt/jwt/v4"
"github.com/google/uuid"
"github.com/trisacrypto/directory/pkg/sectigo"
)

func (s *Server) Login(c *gin.Context) {
in := &sectigo.AuthenticationRequest{}
if err := c.ShouldBindJSON(in); err != nil {
c.JSON(http.StatusBadRequest, Err(err))
return
}

// Very basic auth as this is only used for staging
if in.Username != s.conf.Auth.Username || in.Password != s.conf.Auth.Password {
c.JSON(http.StatusForbidden, Err("could not authenticate user with password"))
return
}

var err error
out := &sectigo.AuthenticationReply{}
if out.AccessToken, out.RefreshToken, err = s.tokens.SignedTokenPair(); err != nil {
c.JSON(http.StatusInternalServerError, Err(err))
return
}

c.JSON(http.StatusOK, out)
}

func (s *Server) Refresh(c *gin.Context) {
token, err := io.ReadAll(c.Request.Body)
if err != nil {
c.JSON(http.StatusBadRequest, Err(err))
return
}

if _, err = s.tokens.Verify(string(token)); err != nil {
fmt.Println(err)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I assume this was left in from debugging?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good catch; thanks!

c.JSON(http.StatusUnauthorized, Err(err))
return
}

out := &sectigo.AuthenticationReply{}
if out.AccessToken, out.RefreshToken, err = s.tokens.SignedTokenPair(); err != nil {
c.JSON(http.StatusInternalServerError, Err(err))
return
}

c.JSON(http.StatusOK, out)
}

func (s *Server) Authenticate(c *gin.Context) {
parts := strings.Split(c.GetHeader("Authorization"), " ")
if len(parts) != 2 || parts[0] != "Bearer" {
c.AbortWithStatusJSON(http.StatusForbidden, Err("authentication required"))
return
}

if _, err := s.tokens.Verify(strings.TrimSpace(parts[1])); err != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, Err("invalid credentials"))
return
}

c.Next()
}

// Token time constraint constants.
Copy link
Collaborator Author

@bbengfort bbengfort Nov 29, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Simple JWT authentication using HS512 signed JWT tokens and a random key that is generated every time the server starts. This mimics the Sectigo API directly.

const (
accessTokenDuration = 1 * time.Hour
refreshTokenDuration = 2 * time.Hour
accessRefreshOverlap = -1 * time.Hour
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I'm reading this correctly, the accessRefreshOverlap specifies the duration for which the access token and refresh token are both valid?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's correct; normally it's -15 minutes but for testing the -1 hour overlaps the access token duration completely.

)

// Global variables that should really not be changed except between major versions.
var (
signingMethod = jwt.SigningMethodHS512
)

type Claims struct {
jwt.RegisteredClaims
Scopes []string `json:"scopes,omitempty"`
FirstLogin bool `json:"first-login"`
}

// A simple token manager that returns jwt.RegisteredClaims with HS512 signatures.
type Tokens struct {
subject string
issuer string
scopes []string
secret []byte
}

func NewTokens(conf AuthConfig) (*Tokens, error) {
if err := conf.Validate(); err != nil {
return nil, err
}

return &Tokens{
subject: conf.Subject,
issuer: conf.Issuer,
scopes: conf.Scopes,
secret: conf.ParseSecret(),
}, nil
}

// Verify an access or refresh token after parsing and return its claims.
func (tm *Tokens) Verify(tks string) (claims *Claims, err error) {
var token *jwt.Token
if token, err = jwt.ParseWithClaims(tks, &Claims{}, tm.keyFunc); err != nil {
return nil, err
}

var ok bool
if claims, ok = token.Claims.(*Claims); ok && token.Valid {
if !claims.VerifyIssuer(tm.issuer, true) {
return nil, fmt.Errorf("invalid issuer %q", claims.Issuer)
}

return claims, nil
}

return nil, fmt.Errorf("could not parse or verify claims from %T", token.Claims)
}

// Sign an access or refresh token and return the token string.
func (tm *Tokens) Sign(token *jwt.Token) (tks string, err error) {
return token.SignedString(tm.secret)
}

// Create signed token pair - an access and refresh token.
func (tm *Tokens) SignedTokenPair() (accessToken, refreshToken string, err error) {
var at *jwt.Token
if at, err = tm.CreateAccessToken(); err != nil {
return "", "", err
}

var rt *jwt.Token
if rt, err = tm.CreateRefreshToken(at); err != nil {
return "", "", err
}

if accessToken, err = tm.Sign(at); err != nil {
return "", "", err
}

if refreshToken, err = tm.Sign(rt); err != nil {
return "", "", err
}
return accessToken, refreshToken, nil
}

// CreateAccessToken from the verified Google credential payload or from an previous
// token if the access token is being reauthorized from previous credentials. Note that
// the returned token only contains the claims and is unsigned.
func (tm *Tokens) CreateAccessToken() (_ *jwt.Token, err error) {
// Create the claims for the access token, using access token defaults.
now := time.Now()
claims := &Claims{
RegisteredClaims: jwt.RegisteredClaims{
ID: uuid.NewString(), // ID is randomly generated and shared between access and refresh tokens.
Subject: tm.subject,
Issuer: tm.issuer,
IssuedAt: jwt.NewNumericDate(now),
NotBefore: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(now.Add(accessTokenDuration)),
},
Scopes: tm.scopes,
FirstLogin: true,
}
return jwt.NewWithClaims(signingMethod, claims), nil
}

// CreateRefreshToken from the Access token claims with predefined expiration. Note that
// the returned token only contains the claims and is unsigned.
func (tm *Tokens) CreateRefreshToken(accessToken *jwt.Token) (refreshToken *jwt.Token, err error) {
accessClaims, ok := accessToken.Claims.(*Claims)
if !ok {
return nil, errors.New("could not retrieve claims from access token")
}

// Create claims for the refresh token from the access token defaults.
claims := &Claims{
RegisteredClaims: jwt.RegisteredClaims{
ID: accessClaims.ID, // ID is randomly generated and shared between access and refresh tokens.
Audience: accessClaims.Audience,
Issuer: accessClaims.Issuer,
Subject: accessClaims.Subject,
IssuedAt: accessClaims.IssuedAt,
NotBefore: jwt.NewNumericDate(accessClaims.ExpiresAt.Add(accessRefreshOverlap)),
ExpiresAt: jwt.NewNumericDate(accessClaims.IssuedAt.Add(refreshTokenDuration)),
},
Scopes: accessClaims.Scopes,
FirstLogin: accessClaims.FirstLogin,
}

return jwt.NewWithClaims(signingMethod, claims), nil
}

func (tm *Tokens) keyFunc(token *jwt.Token) (key interface{}, err error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return tm.secret, nil
}
61 changes: 61 additions & 0 deletions pkg/sectigo/server/auth_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package server_test

import (
"testing"

"github.com/stretchr/testify/require"
"github.com/trisacrypto/directory/pkg/sectigo"
"github.com/trisacrypto/directory/pkg/sectigo/server"
)

func (s *serverTestSuite) TestLogin() {
bbengfort marked this conversation as resolved.
Show resolved Hide resolved
// Create a new client to ensure the client is not logged in
require := s.Require()
profile := sectigo.Config{
Username: "badusername",
Password: "incorrectpassword",
Profile: sectigo.ProfileCipherTraceEE,
Testing: true,
Endpoint: s.srv.URL(),
}
client, err := sectigo.New(profile)
require.NoError(err)

err = client.Authenticate()
require.ErrorIs(err, sectigo.ErrNotAuthorized)

profile.Username = sectigo.MockUsername
profile.Password = sectigo.MockPassword
client, err = sectigo.New(profile)
require.NoError(err)

err = client.Authenticate()
require.NoError(err)
}

func (s *serverTestSuite) TestRefresh() {
require := s.Require()
err := s.client.Refresh()
require.NoError(err)
}

func TestTokens(t *testing.T) {
conf := server.AuthConfig{
Issuer: "http://localhost:8831",
Subject: "testuser",
Scopes: []string{"ROLE_USER"},
}

tokens, err := server.NewTokens(conf)
require.NoError(t, err, "could not create token manager")

ats, rts, err := tokens.SignedTokenPair()
require.NoError(t, err, "could not create signed token pair")
require.NotEmpty(t, rts, "no refresh token returned")

claims, err := tokens.Verify(ats)
require.NoError(t, err, "could not verify access token")
require.Equal(t, conf.Issuer, claims.Issuer)
require.Equal(t, conf.Subject, claims.Subject)
require.Equal(t, conf.Scopes, claims.Scopes)
}
Loading