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:
Reese Norris
2024-10-13 08:22:33 -07:00
parent ef79a3cd91
commit 619c338ec2
4 changed files with 76 additions and 81 deletions

View File

@@ -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:

View File

@@ -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

View File

@@ -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 {

View File

@@ -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]