mirror of
https://github.com/renorris/openfsd
synced 2026-03-22 14:35:36 +08:00
remove PLAINTEXT_PASSWORDS option
dynamically evaluate the token field in the #AP packet. automatically treat as a plaintext password if a JWT token is not detected.
This commit is contained in:
12
README.md
12
README.md
@@ -19,7 +19,7 @@ As of October 2024, FSD is still used to facilitate over 140,000 active members
|
||||
Example:
|
||||
|
||||
```
|
||||
docker run -e IN_MEMORY_DB=true -e PLAINTEXT_PASSWORDS=true \
|
||||
docker run -e IN_MEMORY_DB=true \
|
||||
-p 6809:6809 -p 8080:8080 renorris/openfsd:latest
|
||||
```
|
||||
|
||||
@@ -50,7 +50,6 @@ Use the following environment variables to configure the server:
|
||||
| `MYSQL_ADDR` | | MySQL network address e.g. `127.0.0.1:3306` |
|
||||
| `MYSQL_DBNAME` | | MySQL database name |
|
||||
| `MOTD` | openfsd | "Message of the Day." This text is sent as a chat message to each client upon successful login to FSD. |
|
||||
| `PLAINTEXT_PASSWORDS` | false | Setting this flag disables JSON web token authentication over FSD and instead treats the token field in the #AP packet as a plaintext password.<br>See below for further configuration. |
|
||||
|
||||
For 99.9% of use cases, it is also recommended to set:
|
||||
```
|
||||
@@ -59,7 +58,7 @@ GOMAXPROCS=1
|
||||
This environment variable limits the number of operating system threads that can execute user-level Go code simultaneously.
|
||||
Scaling to multiple threads will generally only make the process slower.
|
||||
|
||||
openfsd supports "fsd-jwt" authentication over HTTP, assuming `PLAINTEXT_PASSWORDS=false` is set.
|
||||
openfsd supports "fsd-jwt" authentication over HTTP.
|
||||
VATSIM uses this standard; clients first obtain a login token by POSTing to `/api/fsd-jwt` with:
|
||||
|
||||
```json
|
||||
@@ -69,8 +68,7 @@ VATSIM uses this standard; clients first obtain a login token by POSTing to `/ap
|
||||
}
|
||||
```
|
||||
|
||||
A token is returned. That token is placed in the
|
||||
token/password field of the `#AP` FSD login packet.
|
||||
A token is returned and placed in the token/password field of the `#AP` FSD login packet.
|
||||
To use a vanilla VATSIM client with openfsd,
|
||||
(except Swift, see below) it will need to be modified to point to openfsd's "fsd-jwt" endpoint:
|
||||
```
|
||||
@@ -108,7 +106,7 @@ Various clients such as [vPilot](https://vpilot.rosscarlson.dev/), [xPilot](http
|
||||
Although it is possible to use vPilot with openfsd, the binary would need to be modified directly.
|
||||
To use xPilot, one would need to manually recompile with the correct JWT token endpoint and FSD server addresses.
|
||||
|
||||
The Swift pilot client works out of the box if openfsd is configured with the `PLAINTEXT_PASSWORDS=true` environment variable option.
|
||||
The Swift pilot client works out of the box.
|
||||
|
||||
**Swift Instructions:**
|
||||
|
||||
@@ -167,7 +165,7 @@ Hex representation:
|
||||
1. **Server Identification packet:** Once the TCP connection has been established, the server identifies itself with a "Server Identification" message, as seen in the example above. The packet identifier is `$DI` (Server Identification). The first field is the "From" field: `SERVER`. The second field is the "To" field: `CLIENT` (`CLIENT` is used here as a generic recipient callsign because the client hasn't announced itself yet.) The third field is the server version identifier. The fourth field is a random hexadecimal string used later for 'VatsimAuth' client verification (see fsd/vatsimauth)
|
||||
|
||||
|
||||
2. **Login Token request:** Up until 2022, user's passwords were sent in plaintext over FSD (yikes.) Now, login tokens are obtained over HTTPS via `auth.vatsim.net/api/fsd-jwt` (implemented in http_server.go.) Now, logging into FSD, this token is sent in place where the old password used to be... in plaintext (still yikes.)
|
||||
2. **Login Token request:** Up until 2022, user's passwords were sent in plaintext over FSD (yikes.) Now, login tokens are obtained over HTTPS via `auth.vatsim.net/api/fsd-jwt` (implemented in http_server.go.) Now, logging into FSD, this token is sent in place where the old password used to be... in plaintext (still yikes.) Note that openfsd dynamically supports both options. A client can send either an obtained JWT token or their plaintext password.
|
||||
|
||||
|
||||
3. **Client Identification packet:** The client identifies itself in its first message:
|
||||
|
||||
@@ -261,8 +261,8 @@ func (c *Connection) attemptLogin() (fsdClient *FSDClient, err error) {
|
||||
var initChallenge string
|
||||
if initChallenge, err = vatsimauth.GenerateChallenge(); err != nil {
|
||||
log.Println("error calling vatsimauth.GenerateChallenge(): " + err.Error())
|
||||
fsdErr := protocol.NewGenericFSDError(protocol.SyntaxError, "", "internal server error (error generating initial challenge)")
|
||||
return nil, fsdErr
|
||||
err = protocol.NewGenericFSDError(protocol.SyntaxError, "", "internal server error (error generating initial challenge)")
|
||||
return
|
||||
}
|
||||
|
||||
// Generate server identification packet
|
||||
@@ -276,27 +276,27 @@ func (c *Connection) attemptLogin() (fsdClient *FSDClient, err error) {
|
||||
|
||||
// Write server identification packet
|
||||
if err = c.WritePacketImmediately(serverIdentPacket); err != nil {
|
||||
fsdErr := protocol.NewGenericFSDError(protocol.SyntaxError, "", "internal server error (error writing $DI server identification packet)")
|
||||
return nil, fsdErr
|
||||
err = protocol.NewGenericFSDError(protocol.SyntaxError, "", "internal server error (error writing $DI server identification packet)")
|
||||
return
|
||||
}
|
||||
|
||||
// Read the first expected packet: client identification
|
||||
var packet string
|
||||
if packet, err = c.ReadPacket(); err != nil {
|
||||
fsdErr := protocol.NewGenericFSDError(protocol.SyntaxError, "", "error reading $ID client identification packet")
|
||||
return nil, fsdErr
|
||||
err = protocol.NewGenericFSDError(protocol.SyntaxError, "", "error reading $ID client identification packet")
|
||||
return
|
||||
}
|
||||
|
||||
// Parse it
|
||||
var clientIdentPDU protocol.ClientIdentificationPDU
|
||||
if err = clientIdentPDU.Parse(packet); err != nil {
|
||||
return nil, err
|
||||
return
|
||||
}
|
||||
|
||||
// Read the second expected packet: add pilot
|
||||
if packet, err = c.ReadPacket(); err != nil {
|
||||
fsdErr := protocol.NewGenericFSDError(protocol.SyntaxError, "", "error reading #AP add pilot packet")
|
||||
return nil, fsdErr
|
||||
err = protocol.NewGenericFSDError(protocol.SyntaxError, "", "error reading #AP add pilot packet")
|
||||
return
|
||||
}
|
||||
|
||||
var addPilotPDU protocol.AddPilotPDU
|
||||
@@ -307,69 +307,33 @@ func (c *Connection) attemptLogin() (fsdClient *FSDClient, err error) {
|
||||
// Handle authentication
|
||||
var networkRating protocol.NetworkRating
|
||||
var pilotRating protocol.PilotRating
|
||||
if servercontext.Config().PlaintextPasswords { // Treat token field as a plaintext password
|
||||
if networkRating, pilotRating, err = verifyPassword(clientIdentPDU.CID, addPilotPDU.Token); err != nil {
|
||||
fsdErr := protocol.NewGenericFSDError(protocol.InvalidLogonError, "", "invalid CID and/or password")
|
||||
return nil, fsdErr
|
||||
}
|
||||
if networkRating, pilotRating, err = handleAuthentication(addPilotPDU.CID, addPilotPDU.Token); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Check if the account is suspended or inactive
|
||||
if addPilotPDU.NetworkRating <= protocol.NetworkRatingSUS {
|
||||
fsdErr := protocol.NewGenericFSDError(protocol.InvalidLogonError, strconv.Itoa(int(addPilotPDU.NetworkRating)), "account suspended/inactive")
|
||||
return nil, fsdErr
|
||||
}
|
||||
|
||||
// Check if the requested PDU rating exceeds their user record
|
||||
if addPilotPDU.NetworkRating > networkRating {
|
||||
fsdErr := protocol.NewGenericFSDError(protocol.RequestedLevelTooHighError, strconv.Itoa(int(addPilotPDU.NetworkRating)), "try again at or below your assigned rating")
|
||||
return nil, fsdErr
|
||||
}
|
||||
|
||||
} else { // Treat token field as a JWT token
|
||||
var token *jwt.Token
|
||||
var verifier auth2.DefaultVerifier
|
||||
if token, err = verifier.VerifyJWT(addPilotPDU.Token); err != nil {
|
||||
fsdErr := protocol.NewGenericFSDError(protocol.InvalidLogonError, "", "invalid token")
|
||||
return nil, fsdErr
|
||||
}
|
||||
|
||||
claims := auth2.FSDJWTClaims{}
|
||||
if err = claims.Parse(token); err != nil {
|
||||
fsdErr := protocol.NewGenericFSDError(protocol.InvalidLogonError, "", "invalid token claims")
|
||||
return nil, fsdErr
|
||||
}
|
||||
|
||||
if claims.CID() != clientIdentPDU.CID {
|
||||
fsdErr := protocol.NewGenericFSDError(protocol.InvalidLogonError, "", "invalid token claims (CID)")
|
||||
return nil, fsdErr
|
||||
}
|
||||
|
||||
if claims.ControllerRating() < addPilotPDU.NetworkRating {
|
||||
fsdErr := protocol.NewGenericFSDError(protocol.RequestedLevelTooHighError, strconv.Itoa(int(claims.ControllerRating())), "try again at or below your assigned rating")
|
||||
return nil, fsdErr
|
||||
}
|
||||
|
||||
networkRating = claims.ControllerRating()
|
||||
pilotRating = claims.PilotRating()
|
||||
// Check for valid network rating
|
||||
if addPilotPDU.NetworkRating > networkRating {
|
||||
err = protocol.NewGenericFSDError(protocol.RequestedLevelTooHighError, strconv.Itoa(int(networkRating)), "try again at or below your assigned rating")
|
||||
return
|
||||
}
|
||||
|
||||
// Check for disallowed callsign
|
||||
switch clientIdentPDU.From {
|
||||
case protocol.ServerCallsign, protocol.ClientQueryBroadcastRecipient, protocol.ClientQueryBroadcastRecipientPilots:
|
||||
fsdErr := protocol.NewGenericFSDError(protocol.CallsignInvalidError, clientIdentPDU.From, "forbidden callsign")
|
||||
return nil, fsdErr
|
||||
err = protocol.NewGenericFSDError(protocol.CallsignInvalidError, clientIdentPDU.From, "forbidden callsign")
|
||||
return
|
||||
}
|
||||
|
||||
// Verify protocol revision
|
||||
if addPilotPDU.ProtocolRevision != protocol.ProtoRevisionVatsim2022 {
|
||||
fsdErr := protocol.NewGenericFSDError(protocol.InvalidProtocolRevisionError, strconv.Itoa(addPilotPDU.ProtocolRevision), "please connect with a client that supports the Vatsim2022 (101) protocol revision")
|
||||
return nil, fsdErr
|
||||
err = protocol.NewGenericFSDError(protocol.InvalidProtocolRevisionError, strconv.Itoa(addPilotPDU.ProtocolRevision), "please connect with a client that supports the Vatsim2022 (101) protocol revision")
|
||||
return
|
||||
}
|
||||
|
||||
// Verify if this browser is supported by vatsimauth
|
||||
if _, ok := vatsimauth.Keys[clientIdentPDU.ClientID]; !ok {
|
||||
fsdErr := protocol.NewGenericFSDError(protocol.UnauthorizedSoftwareError, "", "provided client ID is not supported by vatsimauth")
|
||||
return nil, fsdErr
|
||||
err = protocol.NewGenericFSDError(protocol.UnauthorizedSoftwareError, "", "provided client ID is not supported by vatsimauth")
|
||||
return
|
||||
}
|
||||
|
||||
fsdClient = NewFSDClient(c, &clientIdentPDU, &addPilotPDU, initChallenge, pilotRating)
|
||||
@@ -377,6 +341,43 @@ func (c *Connection) attemptLogin() (fsdClient *FSDClient, err error) {
|
||||
return fsdClient, nil
|
||||
}
|
||||
|
||||
func handleAuthentication(cid int, tokenField string) (networkRating protocol.NetworkRating, pilotRating protocol.PilotRating, err error) {
|
||||
// Check if token field actually contains a JWT token, otherwise treat as plaintext password
|
||||
err = protocol.V.Var(tokenField, "required,jwt")
|
||||
if err == nil {
|
||||
// Treat as JWT token
|
||||
return verifyJWTToken(cid, tokenField)
|
||||
} else {
|
||||
// Treat as plaintext password
|
||||
return verifyPassword(cid, tokenField)
|
||||
}
|
||||
}
|
||||
|
||||
func verifyJWTToken(cid int, tokenField string) (networkRating protocol.NetworkRating, pilotRating protocol.PilotRating, err error) {
|
||||
var token *jwt.Token
|
||||
var verifier auth2.DefaultVerifier
|
||||
if token, err = verifier.VerifyJWT(tokenField); err != nil {
|
||||
err = protocol.NewGenericFSDError(protocol.InvalidLogonError, "", "invalid token")
|
||||
return
|
||||
}
|
||||
|
||||
claims := auth2.FSDJWTClaims{}
|
||||
if err = claims.Parse(token); err != nil {
|
||||
err = protocol.NewGenericFSDError(protocol.InvalidLogonError, "", "invalid token claims")
|
||||
return
|
||||
}
|
||||
|
||||
if claims.CID() != cid {
|
||||
err = protocol.NewGenericFSDError(protocol.InvalidLogonError, "", "invalid token claims (CID)")
|
||||
return
|
||||
}
|
||||
|
||||
networkRating = claims.ControllerRating()
|
||||
pilotRating = claims.PilotRating()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// verifyPassword verifies a password in the cases when PLAINTEXT_PASSWORDS is in use.
|
||||
func verifyPassword(cid int, password string) (networkRating protocol.NetworkRating, pilotRating protocol.PilotRating, err error) {
|
||||
var userRecord database.FSDUserRecord
|
||||
|
||||
@@ -54,18 +54,17 @@ func DataFeed() *datafeed.DataFeed {
|
||||
}
|
||||
|
||||
type ServerConfig struct {
|
||||
FSDListenAddress string `env:"FSD_ADDR, default=0.0.0.0:6809"` // FSD network frontend/port
|
||||
HTTPListenAddress string `env:"HTTP_ADDR, default=0.0.0.0:8080"` // HTTP network frontend/port
|
||||
TLSCertFile string `env:"TLS_CERT_FILE"` // TLS certificate file path
|
||||
TLSKeyFile string `env:"TLS_KEY_FILE"` // TLS key file path
|
||||
MySQLUser string `env:"MYSQL_USER"` // MySQL username
|
||||
MySQLPass string `env:"MYSQL_PASS"` // MySQL password
|
||||
MySQLNet string `env:"MYSQL_NET"` // MySQL network protocol e.g. tcp, unix, etc
|
||||
MySQLAddr string `env:"MYSQL_ADDR"` // MySQL network address e.g. 127.0.0.1:3306
|
||||
MySQLDBName string `env:"MYSQL_DBNAME"` // MySQL database name
|
||||
InMemoryDB bool `env:"IN_MEMORY_DB, default=false"` // Whether to use an ephemeral in-memory DB instead of a real MySQL server
|
||||
MOTD string `env:"MOTD, default=openfsd"` // Server "Message of the Day"
|
||||
PlaintextPasswords bool `env:"PLAINTEXT_PASSWORDS, default=false"` // Whether to enable plaintext FSD passwords
|
||||
FSDListenAddress string `env:"FSD_ADDR, default=0.0.0.0:6809"` // FSD network frontend/port
|
||||
HTTPListenAddress string `env:"HTTP_ADDR, default=0.0.0.0:8080"` // HTTP network frontend/port
|
||||
TLSCertFile string `env:"TLS_CERT_FILE"` // TLS certificate file path
|
||||
TLSKeyFile string `env:"TLS_KEY_FILE"` // TLS key file path
|
||||
MySQLUser string `env:"MYSQL_USER"` // MySQL username
|
||||
MySQLPass string `env:"MYSQL_PASS"` // MySQL password
|
||||
MySQLNet string `env:"MYSQL_NET"` // MySQL network protocol e.g. tcp, unix, etc
|
||||
MySQLAddr string `env:"MYSQL_ADDR"` // MySQL network address e.g. 127.0.0.1:3306
|
||||
MySQLDBName string `env:"MYSQL_DBNAME"` // MySQL database name
|
||||
InMemoryDB bool `env:"IN_MEMORY_DB, default=false"` // Whether to use an ephemeral in-memory DB instead of a real MySQL server
|
||||
MOTD string `env:"MOTD, default=openfsd"` // Server "Message of the Day"
|
||||
}
|
||||
|
||||
type ServerContext struct {
|
||||
|
||||
@@ -24,9 +24,6 @@ func TestStressTest(t *testing.T) {
|
||||
if err := os.Setenv("IN_MEMORY_DB", "true"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Setenv("PLAINTEXT_PASSWORDS", "true"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Start the server
|
||||
ctx, cancelCtx := context.WithCancel(context.Background())
|
||||
@@ -85,7 +82,7 @@ func TestStressTest(t *testing.T) {
|
||||
ticker := time.NewTicker(200 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
|
||||
for i := range 100 {
|
||||
for i := range 10 {
|
||||
randLat := randFloats(-90, 90, 1)[0]
|
||||
randLon := randFloats(-180, 180, 1)[0]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user