Skip to content
This repository has been archived by the owner on Jan 30, 2019. It is now read-only.

Add API authentication mechanism #155

Merged
merged 10 commits into from
Aug 3, 2018
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
66 changes: 63 additions & 3 deletions cmd/soapboxd/main.go
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
package main

import (
"crypto/hmac"
"crypto/sha512"
"database/sql"
"encoding/base64"
"flag"
"fmt"
"log"
"net"
"os"
"os/exec"
"strconv"
"strings"
"time"

"github.com/adhocteam/soapbox"
Expand All @@ -18,6 +22,7 @@ import (
_ "github.com/lib/pq"
"golang.org/x/net/context"
"google.golang.org/grpc"
"google.golang.org/grpc/metadata"
)

func main() {
Expand Down Expand Up @@ -49,8 +54,9 @@ func main() {
}

var opts []grpc.ServerOption
opts = append(opts, serverInterceptor(loginInterceptor))
if *logTiming {
opts = append(opts, serverInterceptor())
opts = append(opts, serverInterceptor(timingInterceptor))
}

server := grpc.NewServer(opts...)
Expand Down Expand Up @@ -103,8 +109,8 @@ func getConfig() *soapbox.Config {
return c
}

func serverInterceptor() grpc.ServerOption {
return grpc.UnaryInterceptor(grpc.UnaryServerInterceptor(timingInterceptor))
func serverInterceptor(interceptor grpc.UnaryServerInterceptor) grpc.ServerOption {
return grpc.UnaryInterceptor(interceptor)
}

func timingInterceptor(
Expand All @@ -118,3 +124,57 @@ func timingInterceptor(
log.Printf("method=%s duration=%s error=%v", info.FullMethod, time.Since(t0), err)
return resp, err
}

func loginInterceptor(
ctx context.Context,
req interface{},
info *grpc.UnaryServerInfo,
handler grpc.UnaryHandler,
) (interface{}, error) {
switch strings.Split(info.FullMethod, "/")[2] {
case "LoginUser", "CreateUser", "GetUser":
return handler(ctx, req)
default:
if err := authorize(ctx); err != nil {
return nil, err
}
return handler(ctx, req)
}
}

type accessDeniedErr struct {
userID []byte
}

func (e *accessDeniedErr) Error() string {
return fmt.Sprintf("Incorrect login token for user %s", e.userID)
}

type emptyMetadataErr struct{}

func (e *emptyMetadataErr) Error() string {
return fmt.Sprint("No metadata attached with request")
}

// TODO(kalilsn) The token calculated here is static, so it can't be revoked, and if stolen
// would allow an attacker to impersonate a user indefinitely.
func authorize(ctx context.Context) error {
if md, ok := metadata.FromIncomingContext(ctx); ok {
userID := []byte(md["user_id"][0])
sentToken, err := base64.StdEncoding.DecodeString(md["login_token"][0])
if err != nil {
return err
}
key := []byte(os.Getenv("LOGIN_SECRET_KEY"))
h := hmac.New(sha512.New, key)
h.Write(userID)
Copy link
Contributor

Choose a reason for hiding this comment

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

If i understand this correctly, the user token is essentially static because Key + UserID should always result in the same token.

This isn't wonderful because it doesn't tie the authorization token to a specific session and it's not revocable. If somebody ever got the token value they could impersonate the user all day long.

Not an immediately critical thing to fix since some authorization is a step forward. But a thing to return to later.

Copy link
Contributor

Choose a reason for hiding this comment

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

Yeah, this doesn't seem great. Stealing this token would let you impersonate the user literally forever. I'll throw in a TODO.

Adding some randomness to the token isn't hard, but if we do that, we have to store it. So perhaps in the future we should put it in the database, tied to the user, along with a timestamp so it will expire automatically?

AFAIK soapboxd doesn't really have the concept of a session yet, so I'm very open to suggestions here.

Copy link
Contributor

Choose a reason for hiding this comment

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

👍

The revocable bit is probably more important (since that's how most API tokens work for command-line etc.)

The session scoping would only make sense if we got rid of the CLI and went web-app only since then you could do an Oauth flow to get a JWT and present that.

Some authentication is better than none so may as well ship this and revisit.

calculated := h.Sum(nil)
if hmac.Equal(sentToken, calculated) {
return nil
}

return &accessDeniedErr{userID}
}

return &emptyMetadataErr{}
}
79 changes: 44 additions & 35 deletions proto/user.pb.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

14 changes: 13 additions & 1 deletion soapboxd/user.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
package soapboxd

import (
"crypto/hmac"
"crypto/sha512"
"database/sql"
"encoding/base64"
"fmt"
"os"

"github.com/adhocteam/soapbox/models"
pb "github.com/adhocteam/soapbox/proto"
Expand Down Expand Up @@ -98,12 +103,13 @@ func (s *server) LoginUser(ctx context.Context, req *pb.LoginUserRequest) (*pb.L
return nil, errors.Cause(err)
}

if err := bcrypt.CompareHashAndPassword([]byte(user.EncryptedPassword), []byte(req.Password)); err != nil {
if err = bcrypt.CompareHashAndPassword([]byte(user.EncryptedPassword), []byte(req.Password)); err != nil {
return res, nil
}

res.User = user
res.Error = ""
res.Hmac = computeHmac512(fmt.Sprint(user.Id), os.Getenv("LOGIN_SECRET_KEY"))

return res, nil
}
Expand All @@ -122,3 +128,9 @@ func (s *server) AssignGithubOmniauthTokenToUser(ctx context.Context, user *pb.U

return user, nil
}

func computeHmac512(message string, secret string) string {
h := hmac.New(sha512.New, []byte(secret))
h.Write([]byte(message))
return base64.StdEncoding.EncodeToString(h.Sum(nil))
}
1 change: 1 addition & 0 deletions soapboxpb/user.proto
Original file line number Diff line number Diff line change
Expand Up @@ -40,4 +40,5 @@ message LoginUserRequest {
message LoginUserResponse {
string error = 1;
User user = 2;
string hmac = 3;
}
2 changes: 1 addition & 1 deletion web/app/controllers/about_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,6 @@

class AboutController < ApplicationController
def index
@version = $api_version_client.get_version(Soapbox::Empty.new)
@version = $api_client.versions.get_version(Soapbox::Empty.new, user_metadata)
end
end
15 changes: 12 additions & 3 deletions web/app/controllers/application_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ class ApplicationController < ActionController::Base
helper_method :get_metrics

def require_login
redirect_to login_user_path unless current_user
redirect_to login_user_path unless current_user && session[:login_token]
end

# Finds the User with the ID stored in the session with the key
Expand All @@ -25,13 +25,22 @@ def refresh_user

def get_user(email)
req = Soapbox::GetUserRequest.new(email: email)
$api_user_client.get_user(req)
$api_client.users.get_user(req)
end

def user_metadata
{
metadata: {
user_id: current_user.id.to_s,
login_token: session[:login_token]
}
}
end

def get_metrics(app_id, metric_type)
req = Soapbox::GetApplicationMetricsRequest.new(id: app_id, metric_type: metric_type)
@metrics = []
app_metrics = $api_client.get_application_metrics(req)
app_metrics = $api_client.applications.get_application_metrics(req)
sorted_metrics = app_metrics.metrics.sort_by {|metric|
Time.parse(metric.time)
}
Expand Down
16 changes: 8 additions & 8 deletions web/app/controllers/applications_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ class ApplicationsController < ApplicationController

def index
req = Soapbox::ListApplicationRequest.new(user_id: current_user.id)
res = $api_client.list_applications(req)
res = $api_client.applications.list_applications(req, user_metadata)
if res.applications.count == 0
redirect_to new_application_path
else
Expand Down Expand Up @@ -49,7 +49,7 @@ def create
github_repo_url: @form.github_repo_url,
type: type,
user_id: current_user.id)
app = $api_client.create_application(app)
app = $api_client.applications.create_application(app, user_metadata)
redirect_to application_path(app.id)
else
render :new
Expand All @@ -58,10 +58,10 @@ def create

def show
req = Soapbox::GetApplicationRequest.new(id: params[:id].to_i)
@app = $api_client.get_application(req)
@app = $api_client.applications.get_application(req, user_metadata)

req = Soapbox::ListDeploymentRequest.new(application_id: params[:id].to_i)
res = $api_deployment_client.list_deployments(req)
res = $api_client.deployments.list_deployments(req, user_metadata)
@deployment = res.deployments.sort_by { |d| -d.created_at.seconds }.first

# strip the oauth token from the URL if present and remove trailing `.git`
Expand All @@ -75,8 +75,8 @@ def show

def destroy
req = Soapbox::GetApplicationRequest.new(id: params[:id].to_i)
@app = $api_client.get_application(req)
$api_client.delete_application(@app)
@app = $api_client.applications.get_application(req)
$api_client.applications.delete_application(@app)
redirect_to application_path(@app.id)
end

Expand All @@ -94,11 +94,11 @@ def find_repositories

def get_environments(app_id)
req = Soapbox::ListEnvironmentRequest.new(application_id: app_id)
$api_environment_client.list_environments(req).environments
$api_client.environments.list_environments(req, user_metadata).environments
end

def get_latest_deploy(app_id, env_id)
req = Soapbox::GetLatestDeploymentRequest.new(application_id: app_id, environment_id: env_id)
$api_deployment_client.get_latest_deployment(req)
$api_client.deployments.get_latest_deployment(req, user_metadata)
end
end
Loading