Commit c4b0fac6 authored by Andrei Mihu's avatar Andrei Mihu
Browse files

Add new wiring for server internals

parent a805c6cc
Loading
Loading
Loading
Loading

server/api.go

0 → 100644
+292 −0
Original line number Diff line number Diff line
// Copyright 2018 The Nakama Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package server

import (
	"database/sql"
	"github.com/golang/protobuf/ptypes/empty"
	"go.uber.org/zap"
	"golang.org/x/net/context"
	"fmt"
	"google.golang.org/grpc"
	"github.com/heroiclabs/nakama/api"
	"github.com/grpc-ecosystem/grpc-gateway/runtime"
	"net/http"
	"github.com/gorilla/handlers"
	"github.com/gorilla/mux"
	"net"
	"google.golang.org/grpc/status"
	"google.golang.org/grpc/codes"
	"google.golang.org/grpc/metadata"
	"strings"
	"encoding/base64"
	"github.com/dgrijalva/jwt-go"
	"crypto"
	"math/rand"
	"time"
	"github.com/satori/go.uuid"
)

// Keys used for storing/retrieving user information in the context of a request after authentication.
type ctxUserIDKey struct{}
type ctxUsernameKey struct{}
type ctxExpiryKey struct{}

type ApiServer struct {
	logger            *zap.Logger
	db                *sql.DB
	config            Config
	random            *rand.Rand
	grpcServer        *grpc.Server
	grpcGatewayServer *http.Server
}

func StartApiServer(logger *zap.Logger, db *sql.DB, config Config, tracker Tracker, registry *SessionRegistry, pipeline *pipeline) *ApiServer {
	grpcServer := grpc.NewServer(
		grpc.UnaryInterceptor(SecurityInterceptorFunc(logger, config)),
	)

	s := &ApiServer{
		logger:         logger,
		db:             db,
		config:         config,
		random:         rand.New(rand.NewSource(time.Now().UnixNano())),
		grpcServer:     grpcServer,
	}

	// Register and start GRPC server.
	api.RegisterNakamaServer(grpcServer, s)
	go func() {
		listener, err := net.Listen("tcp", fmt.Sprintf(":%d", config.GetSocket().Port))
		if err != nil {
			logger.Fatal("API Server listener failed to start", zap.Error(err))
		}

		if err := grpcServer.Serve(listener); err != nil {
			logger.Fatal("API Server listener failed", zap.Error(err))
		}
	}()

	// Register and start GRPC Gateway server.
	// Should start after GRPC server itself because RegisterNakamaHandlerFromEndpoint below tries to dial GRPC.
	ctx := context.Background()
	grpcGateway := runtime.NewServeMux()
	dialAddr := fmt.Sprintf("127.0.0.1:%d", config.GetSocket().Port)
	opts := []grpc.DialOption{grpc.WithInsecure()}
	if err := api.RegisterNakamaHandlerFromEndpoint(ctx, grpcGateway, dialAddr, opts); err != nil {
		logger.Fatal("API Server gateway registration failed", zap.Error(err))
	}

	CORSHeaders := handlers.AllowedHeaders([]string{"Authorization", "Content-Type", "User-Agent"})
	CORSOrigins := handlers.AllowedOrigins([]string{"*"})

	grpcGatewayRouter := mux.NewRouter()
	grpcGatewayRouter.HandleFunc("/ws", NewSocketWsAcceptor(logger, config, tracker, registry, pipeline.processRequest))
	grpcGatewayRouter.NewRoute().Handler(grpcGateway)

	handlerWithCORS := handlers.CORS(CORSHeaders, CORSOrigins)(grpcGatewayRouter)

	s.grpcGatewayServer = &http.Server{
		Addr:    fmt.Sprintf(":%d", config.GetSocket().Port+1),
		Handler: handlerWithCORS,
	}
	go func() {
		if err := s.grpcGatewayServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
			logger.Fatal("API Server gateway listener failed", zap.Error(err))
		}
	}()

	return s
}

func (s *ApiServer) Stop() {
	// 1. Stop GRPC Gateway server first as it sits above GRPC server.
	if err := s.grpcGatewayServer.Shutdown(context.Background()); err != nil {
		s.logger.Error("API Server gateway listener shutdown failed", zap.Error(err))
	}
	// 2. Stop GRPC server.
	s.grpcServer.GracefulStop()
}

func (s *ApiServer) Healthcheck(ctx context.Context, in *empty.Empty) (*empty.Empty, error) {
	return &empty.Empty{}, nil
}

func SecurityInterceptorFunc(logger *zap.Logger, config Config) func(context.Context, interface{}, *grpc.UnaryServerInfo, grpc.UnaryHandler) (interface{}, error) {
	return func(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
		logger.Debug("Security interceptor fired", zap.Any("ctx", ctx), zap.Any("req", req), zap.Any("info", info))
		switch info.FullMethod {
		case "/nakama.proto.Nakama/Healthcheck":
			// Healthcheck has no security.
			return handler(ctx, req)
		case "/nakama.proto.Nakama/AuthenticateCustomFunc":
			fallthrough
		case "/nakama.proto.Nakama/AuthenticateDeviceFunc":
			fallthrough
		case "/nakama.proto.Nakama/AuthenticateEmailFunc":
			fallthrough
		case "/nakama.proto.Nakama/AuthenticateFacebookFunc":
			fallthrough
		case "/nakama.proto.Nakama/AuthenticateGameCenterFunc":
			fallthrough
		case "/nakama.proto.Nakama/AuthenticateGoogleFunc":
			fallthrough
		case "/nakama.proto.Nakama/AuthenticateSteamFunc":
			// Authentication functions require Server key.
			md, ok := metadata.FromIncomingContext(ctx)
			if !ok {
				logger.Error("Cannot extract metadata from incoming context")
				return nil, status.Error(codes.FailedPrecondition, "Cannot extract metadata from incoming context")
			}
			auth, ok := md["authorization"]
			if !ok {
				auth, ok = md["grpcgateway-authorization"]
			}
			if !ok {
				// Neither "authorization" nor "grpc-authorization" were supplied.
				return nil, status.Error(codes.Unauthenticated, "Server key required")
			}
			if len(auth) != 1 {
				// Value of "authorization" or "grpc-authorization" was empty or repeated.
				return nil, status.Error(codes.Unauthenticated, "Server key required")
			}
			username, _, ok := ParseBasicAuth(auth[0])
			if !ok {
				// Value of "authorization" or "grpc-authorization" was malformed.
				return nil, status.Error(codes.Unauthenticated, "Server key invalid")
			}
			if username != config.GetSocket().ServerKey {
				// Value of "authorization" or "grpc-authorization" username component did not match server key.
				return nil, status.Error(codes.Unauthenticated, "Server key invalid")
			}
		case "/nakama.proto.Nakama/RpcFunc":
			// RPC allows full user authentication or HTTP key authentication.
			md, ok := metadata.FromIncomingContext(ctx)
			if !ok {
				logger.Error("Cannot extract metadata from incoming context")
				return nil, status.Error(codes.FailedPrecondition, "Cannot extract metadata from incoming context")
			}
			auth, ok := md["authorization"]
			if !ok {
				auth, ok = md["grpcgateway-authorization"]
			}
			if !ok {
				// Neither "authorization" nor "grpc-authorization" were supplied. Try to validate HTTP key instead.
				in, ok := req.(*api.Rpc)
				if !ok {
					logger.Error("Cannot extract Rpc from incoming request")
					return nil, status.Error(codes.FailedPrecondition, "Auth token or HTTP key required")
				}
				if in.HttpKey == nil {
					// HTTP key not present.
					return nil, status.Error(codes.Unauthenticated, "Auth token or HTTP key required")
				}
				if in.HttpKey.Value != config.GetRuntime().HTTPKey {
					// Value of HTTP key username component did not match.
					return nil, status.Error(codes.Unauthenticated, "HTTP key invalid")
				}
				return handler(ctx, req)
			}
			if len(auth) != 1 {
				// Value of "authorization" or "grpc-authorization" was empty or repeated.
				return nil, status.Error(codes.Unauthenticated, "Auth token invalid")
			}
			userID, username, exp, ok := ParseBearerAuth([]byte(config.GetSession().EncryptionKey), auth[0])
			if !ok {
				// Value of "authorization" or "grpc-authorization" was malformed or expired.
				return nil, status.Error(codes.Unauthenticated, "Auth token invalid")
			}
			ctx = context.WithValue(context.WithValue(context.WithValue(ctx, ctxUserIDKey{}, userID), ctxUsernameKey{}, username), ctxExpiryKey{}, exp)
		default:
			// Unless explicitly defined above, handlers require full user authentication.
			md, ok := metadata.FromIncomingContext(ctx)
			if !ok {
				logger.Error("Cannot extract metadata from incoming context")
				return nil, status.Error(codes.FailedPrecondition, "Cannot extract metadata from incoming context")
			}
			auth, ok := md["authorization"]
			if !ok {
				auth, ok = md["grpcgateway-authorization"]
			}
			if !ok {
				// Neither "authorization" nor "grpc-authorization" were supplied.
				return nil, status.Error(codes.Unauthenticated, "Auth token required")
			}
			if len(auth) != 1 {
				// Value of "authorization" or "grpc-authorization" was empty or repeated.
				return nil, status.Error(codes.Unauthenticated, "Auth token invalid")
			}
			userID, username, exp, ok := ParseBearerAuth([]byte(config.GetSession().EncryptionKey), auth[0])
			if !ok {
				// Value of "authorization" or "grpc-authorization" was malformed or expired.
				return nil, status.Error(codes.Unauthenticated, "Auth token invalid")
			}
			ctx = context.WithValue(context.WithValue(context.WithValue(ctx, ctxUserIDKey{}, userID), ctxUsernameKey{}, username), ctxExpiryKey{}, exp)
		}
		return handler(ctx, req)
	}
}

func ParseBasicAuth(auth string) (username, password string, ok bool) {
	if auth == "" {
		return
	}
	const prefix = "Basic "
	if !strings.HasPrefix(auth, prefix) {
		return
	}
	c, err := base64.StdEncoding.DecodeString(auth[len(prefix):])
	if err != nil {
		return
	}
	cs := string(c)
	s := strings.IndexByte(cs, ':')
	if s < 0 {
		return
	}
	return cs[:s], cs[s+1:], true
}

func ParseBearerAuth(hmacSecretByte []byte, auth string) (userID uuid.UUID, username string, exp int64, ok bool) {
	if auth == "" {
		return
	}
	const prefix = "Bearer "
	if !strings.HasPrefix(auth, prefix) {
		return
	}
	return ParseToken(hmacSecretByte, string(auth[len(prefix):]))
}

func ParseToken(hmacSecretByte []byte, tokenString string) (userID uuid.UUID, username string, exp int64, ok bool) {
	token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
		if s, ok := token.Method.(*jwt.SigningMethodHMAC); !ok || s.Hash != crypto.SHA256 {
			return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
		}
		return hmacSecretByte, nil
	})
	if err != nil {
		return
	}
	claims, ok := token.Claims.(jwt.MapClaims)
	if !ok || !token.Valid {
		return
	}
	userID, err = uuid.FromString(claims["uid"].(string))
	if err != nil {
		return
	}
	return userID, claims["usn"].(string), int64(claims["exp"].(float64)), true
}

server/api.proto

deleted100644 → 0
+0 −1494

File deleted.

Preview size limit exceeded, changes collapsed.

server/api2.proto

deleted100644 → 0
+0 −696

File deleted.

Preview size limit exceeded, changes collapsed.

+10 −25
Original line number Diff line number Diff line
// Copyright 2017 The Nakama Authors
// Copyright 2018 The Nakama Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
@@ -14,31 +14,16 @@

package server

import "go.uber.org/zap"

type SessionFormat int

const (
	SessionFormatProtobuf SessionFormat = 0
	SessionFormatJson                   = 1
import (
	"golang.org/x/net/context"
	"github.com/heroiclabs/nakama/api"
	"github.com/golang/protobuf/ptypes/empty"
)

type session interface {
	Logger() *zap.Logger
	ID() string
	UserID() string

	Handle() string
	SetHandle(string)

	Lang() string
	Expiry() int64
	Consume(func(logger *zap.Logger, session session, envelope *Envelope, reliable bool))
	Unregister()

	Format() SessionFormat
	Send(envelope *Envelope, reliable bool) error
	SendBytes(payload []byte, reliable bool) error
func (s *ApiServer) AccountFetch(ctx context.Context, in *empty.Empty) (*api.Account, error) {
	return &api.Account{Email: "foo@bar.com"}, nil
}

	Close()
func (s *ApiServer) AccountUpdateFunc(ctx context.Context, in *api.AccountUpdate) (*empty.Empty, error) {
	return nil, nil
}
+161 −0
Original line number Diff line number Diff line
// Copyright 2018 The Nakama Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package server

import (
	"golang.org/x/net/context"
	"github.com/heroiclabs/nakama/api"
	"regexp"
	"google.golang.org/grpc/codes"
	"google.golang.org/grpc/status"
	"math/rand"
	"time"
	"github.com/satori/go.uuid"
	"go.uber.org/zap"
	"database/sql"
	"github.com/dgrijalva/jwt-go"
	"strings"
)

var (
	invalidCharsRegex = regexp.MustCompilePOSIX("([[:cntrl:]]|[[:space:]])+")
)

func (s *ApiServer) AuthenticateCustomFunc(ctx context.Context, in *api.AuthenticateCustom) (*api.Session, error) {
	if in.Account == nil || in.Account.Id == "" {
		return nil, status.Error(codes.InvalidArgument, "Custom ID is required")
	} else if invalidCharsRegex.MatchString(in.Account.Id) {
		return nil, status.Error(codes.InvalidArgument, "Custom ID invalid, no spaces or control characters allowed")
	} else if len(in.Account.Id) < 10 || len(in.Account.Id) > 128 {
		return nil, status.Error(codes.InvalidArgument, "Custom ID invalid, must be 10-128 bytes")
	}

	if in.Create == nil || in.Create.Value {
		// Use existing user account if found, otherwise create a new user account.
		username := in.Username
		if username == "" {
			username = generateUsername(s.random)
		} else if invalidCharsRegex.MatchString(username) {
			return nil, status.Error(codes.InvalidArgument, "Username invalid, no spaces or control characters allowed")
		} else if len(username) > 128 {
			return nil, status.Error(codes.InvalidArgument, "Username invalid, must be 1-128 bytes")
		}

		userID := uuid.NewV4().String()
		ts := time.Now().UTC().Unix()
		// NOTE: This query relies on the `custom_id` conflict triggering before the `users_username_key`
		// constraint violation to ensure we fall to the RETURNING case and ignore the new username for
		// existing user accounts. The DO UPDATE SET is to trick the DB into having the data we need to return.
		query := `
INSERT INTO users (id, username, custom_id, created_at, updated_at)
VALUES ($1, $2, $3, $4, $4)
ON CONFLICT (custom_id) DO UPDATE SET custom_id = $3
RETURNING id, username, custom_id, disabled_at`
		params := []interface{}{userID, username, in.Account.Id, ts}

		var dbUserID string
		var dbUsername string
		var dbCustomId sql.NullString
		var dbDisabledAt int64
		err := s.db.QueryRow(query, params...).Scan(&dbUserID, &dbUsername, &dbCustomId, &dbDisabledAt)
		if err != nil {
			if strings.HasSuffix(err.Error(), "violates unique constraint \"users_username_key\"") {
				// Username is already in use by a different account.
				return nil, status.Error(codes.AlreadyExists, "Username is already in use")
			}
			s.logger.Error("Cannot find or create user with custom ID, query error", zap.Error(err))
			return nil, status.Error(codes.Internal, "Error finding or creating user account")
		}

		if dbDisabledAt != 0 {
			return nil, status.Error(codes.PermissionDenied, "User account is disabled")
		}

		token := generateToken(s.config, dbUserID, dbUsername)
		return &api.Session{Token: token}, nil
	} else {
		// Do not create a new user account.
		query := `
SELECT id, username, disabled_at
FROM users
WHERE custom_id = $1`
		params := []interface{}{in.Account.Id}

		var dbUserID string
		var dbUsername string
		var dbDisabledAt int64
		err := s.db.QueryRow(query, params...).Scan(&dbUserID, &dbUsername, &dbDisabledAt)
		if err != nil {
			if err == sql.ErrNoRows {
				// No user account found.
				return nil, status.Error(codes.NotFound, "User account not found")
			} else {
				s.logger.Error("Cannot find user with custom ID, query error", zap.Error(err))
				return nil, status.Error(codes.Internal, "Error finding user user account")
			}
		}

		if dbDisabledAt != 0 {
			return nil, status.Error(codes.PermissionDenied, "User account is disabled")
		}

		token := generateToken(s.config, dbUserID, dbUsername)
		return &api.Session{Token: token}, nil
	}
}

func (s *ApiServer) AuthenticateDeviceFunc(ctx context.Context, in *api.AuthenticateDevice) (*api.Session, error) {
	return nil, nil
}

func (s *ApiServer) AuthenticateEmailFunc(ctx context.Context, in *api.AuthenticateEmail) (*api.Session, error) {
	return nil, nil
}

func (s *ApiServer) AuthenticateFacebookFunc(ctx context.Context, in *api.AuthenticateFacebook) (*api.Session, error) {
	return nil, nil
}

func (s *ApiServer) AuthenticateGameCenterFunc(ctx context.Context, in *api.AuthenticateGameCenter) (*api.Session, error) {
	return nil, nil
}

func (s *ApiServer) AuthenticateGoogleFunc(ctx context.Context, in *api.AuthenticateGoogle) (*api.Session, error) {
	return nil, nil
}

func (s *ApiServer) AuthenticateSteamFunc(ctx context.Context, in *api.AuthenticateSteam) (*api.Session, error) {
	return nil, nil
}

func generateToken(config Config, userID, username string) string {
	exp := time.Now().UTC().Add(time.Duration(config.GetSession().TokenExpiryMs) * time.Millisecond).Unix()
	token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
		"uid": userID,
		"exp": exp,
		"usn": username,
	})
	signedToken, _ := token.SignedString([]byte(config.GetSession().EncryptionKey))
	return signedToken
}

func generateUsername(random *rand.Rand) string {
	const usernameAlphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
	b := make([]byte, 10)
	for i := range b {
		b[i] = usernameAlphabet[random.Intn(len(usernameAlphabet))]
	}
	return string(b)
}
Loading