Initial commit of MVP API
Includes Microsoft->Minecraft OAuth, simple server adding and invite generation, and invite acceptance/RCON whitelisting.
This commit is contained in:
commit
7f1ba6222d
25
.gitignore
vendored
Normal file
25
.gitignore
vendored
Normal file
|
@ -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
|
230
auth/auth.go
Normal file
230
auth/auth.go
Normal file
|
@ -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
|
||||
}
|
18
go.mod
Normal file
18
go.mod
Normal file
|
@ -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
|
||||
)
|
31
go.sum
Normal file
31
go.sum
Normal file
|
@ -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=
|
79
invite/invite.go
Normal file
79
invite/invite.go
Normal file
|
@ -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
|
||||
}
|
43
main.go
Normal file
43
main.go
Normal file
|
@ -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))
|
||||
}
|
35
minecraft/minecraft.go
Normal file
35
minecraft/minecraft.go
Normal file
|
@ -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
|
||||
}
|
303
store/database.go
Normal file
303
store/database.go
Normal file
|
@ -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)
|
||||
}
|
47
store/database.sql
Normal file
47
store/database.sql
Normal file
|
@ -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`)
|
||||
)
|
256
transport/http.go
Normal file
256
transport/http.go
Normal file
|
@ -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))
|
||||
}
|
Loading…
Reference in a new issue