From 619c338ec21836d28990cabeb63b5e425ae9f31a Mon Sep 17 00:00:00 2001 From: Reese Norris Date: Sun, 13 Oct 2024 08:22:33 -0700 Subject: [PATCH] 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. --- README.md | 12 ++-- client/connection.go | 117 +++++++++++++++++---------------- servercontext/servercontext.go | 23 ++++--- test/stress_test.go | 5 +- 4 files changed, 76 insertions(+), 81 deletions(-) diff --git a/README.md b/README.md index 762ca47..e861917 100644 --- a/README.md +++ b/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.
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: diff --git a/client/connection.go b/client/connection.go index f7ba899..865b019 100644 --- a/client/connection.go +++ b/client/connection.go @@ -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 diff --git a/servercontext/servercontext.go b/servercontext/servercontext.go index e62cdb5..70d25b0 100644 --- a/servercontext/servercontext.go +++ b/servercontext/servercontext.go @@ -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 { diff --git a/test/stress_test.go b/test/stress_test.go index c729e0b..526cb2f 100644 --- a/test/stress_test.go +++ b/test/stress_test.go @@ -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]