commit 7f1ba6222d20e36d8f3cd10d2bdf0f18721cb881 Author: Gabriel Simmer Date: Tue Jul 5 11:10:18 2022 +0100 Initial commit of MVP API Includes Microsoft->Minecraft OAuth, simple server adding and invite generation, and invite acceptance/RCON whitelisting. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5b59d10 --- /dev/null +++ b/.gitignore @@ -0,0 +1,25 @@ +# If you prefer the allow list template instead of the deny list, see community template: +# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore +# +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Dependency directories (remove the comment below to include it) +# vendor/ + +# Go workspace file +go.work + +.env +.idea/ +db.sqlite3 \ No newline at end of file diff --git a/auth/auth.go b/auth/auth.go new file mode 100644 index 0000000..84c01b9 --- /dev/null +++ b/auth/auth.go @@ -0,0 +1,230 @@ +package auth + +import ( + "bytes" + "context" + "crypto/tls" + "encoding/json" + "errors" + "fmt" + "golang.org/x/oauth2" + "golang.org/x/oauth2/endpoints" + "io/ioutil" + "net/http" + "os" + "time" + "whitelistmanager/store" +) + +const NoMinecraftOwnership = "minecraft is not owned" + +type MinecraftLoginResponse struct { + Username string `json:"username"` + Roles []string `json:"roles"` + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + ExpiresIn int `json:"expires_in"` +} + +type MinecraftProfile struct { + ID string `json:"id"` + Name string `json:"name"` + Error string `json:"error"` +} + +type XblResponse struct { + IssueInstant time.Time `json:"IssueInstant"` + NotAfter time.Time `json:"NotAfter"` + Token string `json:"Token"` + DisplayClaims DisplayClaims `json:"DisplayClaims"` +} +type Xui struct { + Uhs string `json:"uhs"` +} +type DisplayClaims struct { + Xui []Xui `json:"xui"` +} + +type MinecraftAuth struct { + IdentityToken string `json:"identityToken"` +} + +func LoginUrl() string { + oaConfig := &oauth2.Config{ + ClientID: os.Getenv("AZURE_OAUTH_CLIENT_ID"), + ClientSecret: os.Getenv("AZURE_OAUTH_CLIENT_SECRET"), + Endpoint: endpoints.Microsoft, + Scopes: []string{"Xboxlive.signin", "Xboxlive.offline_access"}, + } + return oaConfig.AuthCodeURL("foo") +} + +func Authenticate(code string) (store.User, error) { + oaConfig := &oauth2.Config{ + ClientID: os.Getenv("AZURE_OAUTH_CLIENT_ID"), + ClientSecret: os.Getenv("AZURE_OAUTH_CLIENT_SECRET"), + Endpoint: endpoints.Microsoft, + Scopes: []string{"Xboxlive.signin", "Xboxlive.offline_access"}, + } + token, _ := oaConfig.Exchange(context.Background(), code) + client := oaConfig.Client(context.Background(), token) + client.Transport = &http.Transport{ + TLSClientConfig: &tls.Config{ + Renegotiation: tls.RenegotiateOnceAsClient, + InsecureSkipVerify: true, + }, + } + + minecraftToken, userProfile, err := minecraftTokenExchange(token, client) + if err != nil { + return store.User{}, err + } + + user := store.User{ + Id: userProfile.ID, + Token: minecraftToken, + DisplayName: userProfile.Name, + RefreshToken: token.RefreshToken, + TokenExpiry: token.Expiry, + } + + return user, nil +} + +func xblTokenExchange(token *oauth2.Token, client *http.Client) (string, string, error) { + xblPayload := map[string]interface{}{ + "RelyingParty": "http://auth.xboxlive.com", + "TokenType": "JWT", + "Properties": map[string]interface{}{ + "AuthMethod": "RPS", + "SiteName": "user.auth.xboxlive.com", + "RpsTicket": "d=" + token.AccessToken, + }, + } + + encoded, err := json.Marshal(xblPayload) + if err != nil { + return "", "", err + } + request, err := http.NewRequest("POST", "https://user.auth.xboxlive.com/user/authenticate", bytes.NewReader(encoded)) + if err != nil { + return "", "", err + } + request.Header.Set("Content-Type", "application/json") + request.Header.Set("Accept", "application/json") + request.Header.Set("x-xbl-contract-version", "1") + + resp, err := client.Do(request) + if err != nil { + return "", "", err + } + b, err := ioutil.ReadAll(resp.Body) + if err != nil { + return "", "", err + } + + var xblData XblResponse + err = json.Unmarshal(b, &xblData) + if err != nil { + return "", "", err + } + + return xblData.Token, xblData.DisplayClaims.Xui[0].Uhs, nil +} + +func xstsTokenExchange(xblToken string, client *http.Client) (string, error) { + body := map[string]interface{}{ + "Properties": map[string]interface{}{ + "SandboxId": "RETAIL", + "UserTokens": []string{ + xblToken, + }, + }, + "RelyingParty": "rp://api.minecraftservices.com/", + "TokenType": "JWT", + } + + encoded, err := json.Marshal(body) + if err != nil { + return "", err + } + request, err := http.NewRequest("POST", "https://xsts.auth.xboxlive.com/xsts/authorize", bytes.NewReader(encoded)) + if err != nil { + return "", err + } + request.Header.Set("Content-Type", "application/json") + request.Header.Set("Accept", "application/json") + request.Header.Set("x-xbl-contract-version", "1") + + resp, err := client.Do(request) + + if err != nil { + return "", err + } + b, err := ioutil.ReadAll(resp.Body) + if err != nil { + return "", err + } + var jsonResponse map[string]interface{} + + err = json.Unmarshal(b, &jsonResponse) + if err != nil { + return "", err + } + + return jsonResponse["Token"].(string), nil +} + +// Exchanges our Microsoft OAuth token for Xbox Live -> XSTS -> Mojang +func minecraftTokenExchange(token *oauth2.Token, client *http.Client) (string, MinecraftProfile, error) { + xblToken, userHash, err := xblTokenExchange(token, client) + if err != nil { + return "", MinecraftProfile{}, err + } + xstsToken, err := xstsTokenExchange(xblToken, client) + if err != nil { + return "", MinecraftProfile{}, err + } + body := MinecraftAuth{ + IdentityToken: fmt.Sprintf("XBL3.0 x=%s;%s", userHash, xstsToken), + } + + encoded, err := json.Marshal(body) + if err != nil { + return "", MinecraftProfile{}, err + } + request, err := http.NewRequest("POST", "https://api.minecraftservices.com/authentication/login_with_xbox", bytes.NewReader(encoded)) + if err != nil { + return "", MinecraftProfile{}, err + } + response, err := client.Do(request) + if err != nil { + return "", MinecraftProfile{}, err + } + + var mcr MinecraftLoginResponse + err = json.NewDecoder(response.Body).Decode(&mcr) + if err != nil { + return "", MinecraftProfile{}, err + } + verificationReq, err := http.NewRequest("GET", "https://api.minecraftservices.com/minecraft/profile", nil) + if err != nil { + return "", MinecraftProfile{}, err + } + verificationReq.Header.Set("Authorization", fmt.Sprintf("Bearer %s", mcr.AccessToken)) + resp, err := client.Do(verificationReq) + if err != nil { + return "", MinecraftProfile{}, err + } + var mcp MinecraftProfile + err = json.NewDecoder(resp.Body).Decode(&mcp) + if err != nil { + return "", MinecraftProfile{}, err + } + if mcp.Error != "" && mcp.ID == "" { + // User does not own the game, or no ID comes back + return "", MinecraftProfile{}, errors.New(NoMinecraftOwnership) + } + + return mcr.AccessToken, mcp, nil +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..5c08a4b --- /dev/null +++ b/go.mod @@ -0,0 +1,18 @@ +module whitelistmanager + +go 1.18 + +require ( + github.com/Kelwing/mc-rcon v0.0.0-20220214194105-bec8dcbccc3f + github.com/alexedwards/flow v0.0.0-20220607190737-c48a87f2b4c4 + github.com/google/uuid v1.3.0 + github.com/mattn/go-sqlite3 v1.14.14 + golang.org/x/oauth2 v0.0.0-20220630143837-2104d58473e0 +) + +require ( + github.com/golang/protobuf v1.5.2 // indirect + golang.org/x/net v0.0.0-20220624214902-1bab6f366d9e // indirect + google.golang.org/appengine v1.6.7 // indirect + google.golang.org/protobuf v1.28.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..c375ca4 --- /dev/null +++ b/go.sum @@ -0,0 +1,31 @@ +github.com/Kelwing/mc-rcon v0.0.0-20220214194105-bec8dcbccc3f h1:ySopb0/agJ4rwNsIthx9uguIlbUEnjSVKcao3n9ZPEY= +github.com/Kelwing/mc-rcon v0.0.0-20220214194105-bec8dcbccc3f/go.mod h1:wlXY/EO9CBjMViEh95JOQhp//RMnHs4ZUBhYhx43LdM= +github.com/alexedwards/flow v0.0.0-20220607190737-c48a87f2b4c4 h1:tF8vI7d4pKpByHydzNUDsOuZMOqmuyt+8/HuSN0hzGA= +github.com/alexedwards/flow v0.0.0-20220607190737-c48a87f2b4c4/go.mod h1:1rjOQiOqQlmMdUMuvlJFjldqTnE/tQULE7qPIu4aq3U= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw= +github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg= +github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= +github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/mattn/go-sqlite3 v1.14.14 h1:qZgc/Rwetq+MtyE18WhzjokPD93dNqLGNT3QJuLvBGw= +github.com/mattn/go-sqlite3 v1.14.14/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= +golang.org/x/net v0.0.0-20220624214902-1bab6f366d9e h1:TsQ7F31D3bUCLeqPT0u+yjp1guoArKaNKmCr22PYgTQ= +golang.org/x/net v0.0.0-20220624214902-1bab6f366d9e/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/oauth2 v0.0.0-20220630143837-2104d58473e0 h1:VnGaRqoLmqZH/3TMLJwYCEWkR4j1nuIU1U9TvbqsDUw= +golang.org/x/oauth2 v0.0.0-20220630143837-2104d58473e0/go.mod h1:h4gKUeWbJ4rQPri7E0u6Gs4e9Ri2zaLxzw5DI5XGrYg= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= +google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.28.0 h1:w43yiav+6bVFTBQFZX0r7ipe9JQ1QsbMgHwbBziscLw= +google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= diff --git a/invite/invite.go b/invite/invite.go new file mode 100644 index 0000000..a1b9199 --- /dev/null +++ b/invite/invite.go @@ -0,0 +1,79 @@ +package invite + +import ( + "crypto/rand" + "database/sql" + "errors" + "math/big" + "whitelistmanager/store" +) + +const NotOwnerofServer = "user is not owner of server" + +type Manager struct { + store store.Storer +} + +type InviteManager interface { + Create(in store.Invite, user store.User) (string, error) + RemainingUses(in store.Invite) (int, error) +} + +func NewManager(db store.Storer) *Manager { + return &Manager{store: db} +} + +func (i *Manager) Create(in store.Invite, user store.User) (string, error) { + server, err := i.store.GetServer(in.Server.Id) + if server.Owner.Id != user.Id { + return "", errors.New(NotOwnerofServer) + } + + token, err := i.createToken() + if err != nil { + return "", err + } + + // If we have an unlikely collision, generate a new token. + _, err = i.store.GetInvite(token) + if err != nil { + if !errors.Is(err, sql.ErrNoRows) { + return "", err + } + } else { + token, err = i.createToken() + if err != nil { + return "", err + } + } + + in.Token = token + in.Creator = user + return token, i.store.SaveInvite(in) +} + +func (i *Manager) createToken() (string, error) { + const letters = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-" + ret := make([]byte, 8) + for i := 0; i < 8; i++ { + num, err := rand.Int(rand.Reader, big.NewInt(int64(len(letters)))) + if err != nil { + return "", err + } + ret[i] = letters[num.Int64()] + } + + return string(ret), nil +} + +func (i *Manager) RemainingUses(in store.Invite) (int, error) { + if in.Unlimited { + return 1, nil + } + logs, err := i.store.InviteLog(in) + if err != nil { + return 0, nil + } + + return in.Uses - len(logs), nil +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..9d52ec1 --- /dev/null +++ b/main.go @@ -0,0 +1,43 @@ +package main + +import ( + "github.com/alexedwards/flow" + "log" + "net/http" + "whitelistmanager/store" + "whitelistmanager/transport" +) + +func main() { + mux := flow.New() + db, err := store.Open() + if err != nil { + panic(err) + } + handlers := transport.New(db) + // Auth endpoints + mux.Group(func(mux *flow.Mux) { + mux.HandleFunc("/api/v1/auth/redirect", handlers.AuthRedirect) + mux.HandleFunc("/api/v1/auth/callback", handlers.AuthCallback) + }) + + // API endpoints + mux.Group(func(mux *flow.Mux) { + mux.Use(handlers.Cors) + mux.Use(handlers.SessionAuth) + mux.HandleFunc("/api/v1/me", handlers.CurrentUser, "GET") + + mux.HandleFunc("/api/v1/invites", handlers.CreateInvite, "POST") + mux.HandleFunc("/api/v1/invite/:id", handlers.GetInvite, "GET", "DELETE") + mux.HandleFunc("/api/v1/invite/:id/accept", handlers.AcceptInvite, "POST") + + mux.HandleFunc("/api/v1/servers", handlers.Server, "GET", "POST") + }) + + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + r.Header.Set("Content-Type", "application/json") + w.Write([]byte(`{"duck": "quacks"}`)) + }) + + log.Fatal(http.ListenAndServe("0.0.0.0:8080", mux)) +} diff --git a/minecraft/minecraft.go b/minecraft/minecraft.go new file mode 100644 index 0000000..a2e2fb6 --- /dev/null +++ b/minecraft/minecraft.go @@ -0,0 +1,35 @@ +package minecraft + +import ( + "errors" + "fmt" + mcrcon "github.com/Kelwing/mc-rcon" + "whitelistmanager/store" +) + +const ( + RconConnectionError = "rcon connection failed" + RconAuthError = "failed to authenticate with rcon server" + RconCommandError = "command failed" +) + +func Whitelist(user string, server store.Server) (string, error) { + conn := new(mcrcon.MCConn) + err := conn.Open(server.Rcon.Address, server.Rcon.Password) + if err != nil { + return "", errors.New(RconConnectionError) + } + defer conn.Close() + + err = conn.Authenticate() + if err != nil { + return "", errors.New(RconAuthError) + } + + command := fmt.Sprintf("whitelist add %s", user) + resp, err := conn.SendCommand(command) + if err != nil { + return "", errors.New(RconCommandError) + } + return resp, nil +} diff --git a/store/database.go b/store/database.go new file mode 100644 index 0000000..509b03f --- /dev/null +++ b/store/database.go @@ -0,0 +1,303 @@ +package store + +import ( + "database/sql" + "embed" + "errors" + "github.com/google/uuid" + _ "github.com/mattn/go-sqlite3" + "log" + "os" + "time" +) + +//go:embed database.sql +var migrations embed.FS + +const SessionExpired = "session expired" + +type Store struct { + database *sql.DB +} + +type Storer interface { + SaveInvite(invite Invite) error + GetInvite(token string) (Invite, error) + GetServer(id string) (Server, error) + SaveServer(server Server) error + GetUserServers(user User) ([]Server, error) + LogInviteUse(user string, invite Invite) error + InviteLog(invite Invite) ([]InviteLog, error) + GetUser(uid string) (User, error) + SaveUser(user User) error + SaveSession(token string, user User) error + SessionUser(token string) (User, error) +} + +type Invite struct { + Token string `json:"token"` + Creator User `json:"creator"` + Server Server `json:"server"` + Uses int `json:"uses"` + Unlimited bool `json:"unlimited"` +} + +type Server struct { + Id string `json:"id"` + Name string `json:"name"` + Address string `json:"address"` + Rcon Rcon `json:"rcon,omitempty"` + Owner User `json:"-"` +} + +type User struct { + Id string `json:"id,omitempty"` + Token string `json:"-"` + DisplayName string `json:"display_name"` + RefreshToken string `json:"-"` + TokenExpiry time.Time `json:"-"` +} + +type Rcon struct { + Address string `json:"address,omitempty"` + Password string `json:"password,omitempty"` +} + +type Session struct { + Token string + UID string + Expiry time.Time +} + +func Open() (*Store, error) { + database := os.Getenv("WLM_DATABASE_PATH") + if database == "" { + database = "db.sqlite3" + } + + if _, err := os.Stat(database); errors.Is(err, os.ErrNotExist) { + log.Printf("No database found at %s, creating", database) + _, err := os.Create(database) + if err != nil { + return nil, err + } + db, err := sql.Open("sqlite3", database) + if err != nil { + return nil, err + } + initialSetup, err := migrations.ReadFile("database.sql") + if err != nil { + return nil, err + } + _, err = db.Exec(string(initialSetup)) + if err != nil { + return nil, err + } + log.Printf("Database created at %s", database) + db.Close() + } + + db, err := sql.Open("sqlite3", database) + if err != nil { + return nil, err + } + return &Store{database: db}, nil +} + +func (s *Store) SaveInvite(invite Invite) error { + q, err := s.database.Prepare("INSERT INTO invites (token, creator, server, uses, unlimited) VALUES ($1, $2, $3, $4, $5)") + if err != nil { + return err + } + + _, err = q.Exec(invite.Token, invite.Creator.Id, invite.Server.Id, invite.Uses, invite.Unlimited) + if err != nil { + return err + } + + return nil +} + +func (s *Store) GetInvite(token string) (Invite, error) { + if token == "" { + return Invite{}, sql.ErrNoRows + } + q := s.database.QueryRow("SELECT * FROM invites WHERE token=$1", token) + + var in Invite + err := q.Scan(&in.Token, &in.Creator.Id, &in.Server.Id, &in.Uses, &in.Unlimited) + if err != nil { + return Invite{}, err + } + + in.Server, err = s.GetServer(in.Server.Id) + if err != nil { + return Invite{}, err + } + in.Server.Rcon = Rcon{} + + q = s.database.QueryRow("SELECT display_name FROM users WHERE id=$1", in.Creator.Id) + err = q.Scan(&in.Creator.DisplayName) + if err != nil { + return Invite{}, err + } + + return in, nil +} + +func (s *Store) LogInviteUse(user string, invite Invite) error { + q, err := s.database.Prepare("INSERT INTO invite_log (entry_id, invite, user) VALUES ($1, $2, $3)") + if err != nil { + return err + } + entryId := uuid.New().String() + + _, err = q.Exec(entryId, invite.Token, user) + return err +} + +type InviteLog struct { + EntryID string + Invite string + User string +} + +func (s *Store) InviteLog(invite Invite) ([]InviteLog, error) { + q, err := s.database.Query("SELECT * FROM invite_log WHERE invite=$1", invite.Token) + if err != nil { + return []InviteLog{}, nil + } + + var log []InviteLog + for q.Next() { + var logEntry InviteLog + err := q.Scan(&logEntry.EntryID, &logEntry.Invite, &logEntry.User) + if err != nil { + continue + } + log = append(log, logEntry) + } + + return log, nil +} + +func (s *Store) GetServer(id string) (Server, error) { + if id == "" { + return Server{}, sql.ErrNoRows + } + q := s.database.QueryRow("SELECT * FROM servers WHERE id=$1", id) + + var serv Server + err := q.Scan(&serv.Id, &serv.Name, &serv.Address, &serv.Rcon.Address, &serv.Rcon.Password, &serv.Owner.Id) + if err != nil { + return Server{}, err + } + + return serv, nil +} + +func (s *Store) GetUserServers(user User) ([]Server, error) { + q, err := s.database.Query("SELECT id,address,name,rcon_address FROM servers WHERE owner=$1", user.Id) + if err != nil { + return []Server{}, nil + } + + var servers []Server + for q.Next() { + var server Server + err := q.Scan(&server.Id, &server.Address, &server.Name, &server.Rcon.Address) + if err != nil { + continue + } + servers = append(servers, server) + } + + return servers, nil +} + +func (s *Store) SaveServer(server Server) error { + q, err := s.database.Prepare("INSERT INTO servers (id, address, name, rcon_address, rcon_password, owner) VALUES ($1, $2, $3, $4, $5, $6)") + if err != nil { + return err + } + _, err = q.Exec(server.Id, server.Address, server.Name, server.Rcon.Address, server.Rcon.Password, server.Owner.Id) + if err != nil { + return err + } + + return nil +} + +func (s *Store) GetUser(uid string) (User, error) { + q := s.database.QueryRow("SELECT * FROM users WHERE id=$1", uid) + + var user User + var tokenExpiry string + err := q.Scan(&user.Id, &user.Token, &user.DisplayName, &user.RefreshToken, &tokenExpiry) + if err != nil { + return User{}, err + } + user.TokenExpiry, err = time.Parse("2006-01-02 15:04:05.999999999-07:00", tokenExpiry) + if err != nil { + return User{}, err + } + + return user, nil +} + +func (s *Store) SaveUser(user User) error { + existingUser, err := s.GetUser(user.Id) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + q, err := s.database.Prepare("INSERT INTO users (id, token, display_name, refresh_token, token_expiry) VALUES ($1, $2, $3, $4, $5)") + if err != nil { + return err + } + + _, err = q.Exec(user.Id, user.Token, user.DisplayName, user.RefreshToken, user.TokenExpiry) + if err != nil { + return err + } + + return nil + } else { + return err + } + } + q, err := s.database.Prepare("UPDATE users SET token=$2, display_name=$3, refresh_token=$4, token_expiry=$5 WHERE id=$1") + if err != nil { + return err + } + + _, err = q.Exec(existingUser.Id, user.Token, user.DisplayName, user.RefreshToken, user.TokenExpiry) + return err +} + +func (s *Store) SaveSession(token string, user User) error { + q, err := s.database.Prepare("INSERT INTO sessions (token, uid, expiry) VALUES ($1, $2, $3)") + if err != nil { + return err + } + // Expire in 30 days. + _, err = q.Exec(token, user.Id, time.Now().Add(720*time.Hour)) + return err +} + +func (s *Store) SessionUser(token string) (User, error) { + q := s.database.QueryRow("SELECT * FROM sessions WHERE token=$1", token) + var sess Session + var sessExpiry string + err := q.Scan(&sess.Token, &sess.UID, &sessExpiry) + if err != nil { + return User{}, err + } + sess.Expiry, err = time.Parse("2006-01-02 15:04:05.999999999-07:00", sessExpiry) + if err != nil { + return User{}, err + } + if sess.Expiry.Before(time.Now()) { + return User{}, errors.New(SessionExpired) + } + + return s.GetUser(sess.UID) +} diff --git a/store/database.sql b/store/database.sql new file mode 100644 index 0000000..8d0d77d --- /dev/null +++ b/store/database.sql @@ -0,0 +1,47 @@ +-- noinspection SqlNoDataSourceInspectionForFile + +CREATE TABLE `invites` ( + `token` TEXT NOT NULL, + `creator` TEXT NOT NULL, + `server` TEXT NOT NULL, + `uses` INT NOT NULL, + `unlimited` INTEGER NOT NULL, + PRIMARY KEY (`token`), + FOREIGN KEY (`server`) REFERENCES servers(`id`), + FOREIGN KEY (`creator`) REFERENCES users(`id`) +) STRICT; + +CREATE TABLE `servers` ( + `id` TEXT NOT NULL, + `name` TEXT NOT NULL, + `address` TEXT NOT NULL, + `rcon_address` TEXT NOT NULL, + `rcon_password` TEXT NOT NULL, + `owner` TEXT NOT NULL, + PRIMARY KEY (`id`) +) STRICT; + +CREATE TABLE `users` ( + `id` TEXT NOT NULL, + `token` TEXT NOT NULL, + `display_name` TEXT NOT NULL, + `refresh_token` TEXT NOT NULL, + `token_expiry` TEXT NOT NULL, + PRIMARY KEY (`id`) +) STRICT; + +CREATE TABLE `sessions` ( + `token` TEXT NOT NULL, + `uid` TEXT NOT NULL, + `expiry` TEXT NOT NULL, + PRIMARY KEY (`token`), + FOREIGN KEY (`uid`) REFERENCES users(`id`) +) STRICT; + +CREATE TABLE `invite_log` ( + `entry_id` TEXT NOT NULL, + `invite` TEXT NOT NULL, + `user` TEXT NOT NULL, + PRIMARY KEY (`entry_id`), + FOREIGN KEY (`invite`) REFERENCES invites(`token`) +) \ No newline at end of file diff --git a/transport/http.go b/transport/http.go new file mode 100644 index 0000000..ed2aaa6 --- /dev/null +++ b/transport/http.go @@ -0,0 +1,256 @@ +package transport + +import ( + "context" + "crypto/rand" + "database/sql" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "github.com/alexedwards/flow" + "github.com/google/uuid" + "log" + "net/http" + "whitelistmanager/auth" + "whitelistmanager/invite" + "whitelistmanager/minecraft" + "whitelistmanager/store" +) + +type Handler struct { + store store.Storer + manager invite.InviteManager +} + +type Handle interface { + AuthMiddleware(next http.Handler) http.Handler + Cors(next http.Handler) http.Handler + CurrentUser(w http.ResponseWriter, r *http.Request) + CreateInvite(w http.ResponseWriter, r *http.Request) + GetInvite(w http.ResponseWriter, r *http.Request) + AcceptInvite(w http.ResponseWriter, r *http.Request) + AuthRedirect(w http.ResponseWriter, r *http.Request) + AuthCallback(w http.ResponseWriter, r *http.Request) + CreateServer(w http.ResponseWriter, r *http.Request) +} + +func New(store store.Storer) *Handler { + im := invite.NewManager(store) + return &Handler{ + store: store, + manager: im, + } +} + +func (h *Handler) Cors(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type,Authorization") + w.Header().Set("Access-Control-Allow-Methods", "*") + next.ServeHTTP(w, r) + }) +} + +func (h *Handler) SessionAuth(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + session, err := r.Cookie("session") + if err != nil { + log.Println(err) + http.Redirect(w, r, "/api/v1/auth/redirect", http.StatusTemporaryRedirect) + return + } + user, err := h.store.SessionUser(session.Value) + if err != nil { + log.Println(err) + http.Redirect(w, r, "/api/v1/auth/redirect", http.StatusTemporaryRedirect) + return + } + ctx := context.WithValue(r.Context(), "user", user) + r = r.WithContext(ctx) + next.ServeHTTP(w, r) + }) +} + +type invitePayload struct { + Server string `json:"server"` + Unlimited bool `json:"unlimited"` + Uses int `json:"uses"` +} + +func (h *Handler) CreateInvite(w http.ResponseWriter, r *http.Request) { + var i invitePayload + err := json.NewDecoder(r.Body).Decode(&i) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + user := r.Context().Value("user").(store.User) + in, err := h.manager.Create(store.Invite{ + Server: store.Server{Id: i.Server}, + Unlimited: i.Unlimited, + Uses: i.Uses, + }, user) + if err != nil { + if err.Error() == invite.NotOwnerofServer { + http.Error(w, err.Error(), http.StatusForbidden) + return + } + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Write([]byte(in)) +} + +func (h *Handler) GetInvite(w http.ResponseWriter, r *http.Request) { + token := flow.Param(r.Context(), "id") + in, err := h.store.GetInvite(token) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + http.Error(w, "invalid invite", http.StatusNotFound) + return + } + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + err = json.NewEncoder(w).Encode(in) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } +} + +func (h *Handler) AcceptInvite(w http.ResponseWriter, r *http.Request) { + token := flow.Param(r.Context(), "id") + in, err := h.store.GetInvite(token) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + http.Error(w, "invalid invite", http.StatusNotFound) + return + } + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + remainingUse, err := h.manager.RemainingUses(in) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + if remainingUse == 0 { + http.Error(w, "no more uses remaining for invite", http.StatusForbidden) + return + } + + server, err := h.store.GetServer(in.Server.Id) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + user := r.Context().Value("user").(store.User) + log.Println(user.DisplayName) + resp, err := minecraft.Whitelist(user.DisplayName, server) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + if resp == fmt.Sprintf("Added %s to the whitelist", user.DisplayName) { + err = h.store.LogInviteUse(user.DisplayName, in) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + } + + w.Write([]byte(resp)) +} + +func (h *Handler) AuthRedirect(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, auth.LoginUrl(), http.StatusTemporaryRedirect) +} + +func (h *Handler) AuthCallback(w http.ResponseWriter, r *http.Request) { + code := r.URL.Query()["code"][0] + user, err := auth.Authenticate(code) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + err = h.store.SaveUser(user) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + sessToken, err := generateSessionToken() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + http.SetCookie(w, &http.Cookie{ + Name: "session", + Value: sessToken, + HttpOnly: true, + Path: "/", + }) + err = h.store.SaveSession(sessToken, user) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + http.Redirect(w, r, "/", http.StatusTemporaryRedirect) +} + +func generateSessionToken() (string, error) { + b := make([]byte, 64) + _, err := rand.Read(b) + if err != nil { + return "", err + } + + return base64.URLEncoding.EncodeToString(b), err +} + +func (h *Handler) Server(w http.ResponseWriter, r *http.Request) { + user := r.Context().Value("user").(store.User) + if r.Method == http.MethodGet { + servers, err := h.store.GetUserServers(user) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + json.NewEncoder(w).Encode(servers) + return + } + + var server store.Server + err := json.NewDecoder(r.Body).Decode(&server) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + server.Id = uuid.New().String() + server.Owner.Id = user.Id + + err = h.store.SaveServer(server) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + fmt.Fprintf(w, "created server %s", server.Id) +} + +func (h *Handler) CurrentUser(w http.ResponseWriter, r *http.Request) { + value := r.Context().Value("user") + if value == nil { + http.Error(w, "", http.StatusForbidden) + return + } + user := value.(store.User) + w.Write([]byte(user.DisplayName)) +}