package foodgroup import ( "bytes" "context" "encoding/xml" "errors" "fmt" "log/slog" "strconv" "strings" "time" "github.com/mk6i/open-oscar-server/config" "github.com/mk6i/open-oscar-server/state" "github.com/mk6i/open-oscar-server/wire" "github.com/google/uuid" ) // MaxConcurrentLoginsPerUser is the maximum number of concurrent logins allowed // for a single user. const MaxConcurrentLoginsPerUser = 5 // NewAuthService creates a new instance of AuthService. func NewAuthService( cfg config.Config, sessionManager SessionRegistry, sessionRetriever SessionRetriever, chatSessionRegistry ChatSessionRegistry, userManager UserManager, cookieBaker CookieBaker, chatMessageRelayer ChatMessageRelayer, accountManager AccountManager, bartItemManager BARTItemManager, feedbagManager FeedbagManager, classes wire.RateLimitClasses, createAccount state.CreateAccountFunc, logger *slog.Logger, ) *AuthService { return &AuthService{ chatSessionRegistry: chatSessionRegistry, config: cfg, cookieBaker: cookieBaker, sessionManager: sessionManager, sessionRetriever: sessionRetriever, userManager: userManager, chatMessageRelayer: chatMessageRelayer, accountManager: accountManager, bartItemManager: bartItemManager, feedbagManager: feedbagManager, rateLimitClasses: classes, timeNow: time.Now, maxConcurrentLoginsPerUser: MaxConcurrentLoginsPerUser, createAccount: createAccount, logger: logger, } } // AuthService provides client login and session management services. It // supports both FLAP (AIM v1.0-v3.0) and BUCP (AIM v3.5-v5.9) authentication // modes. type AuthService struct { chatMessageRelayer ChatMessageRelayer chatSessionRegistry ChatSessionRegistry config config.Config cookieBaker CookieBaker logger *slog.Logger sessionManager SessionRegistry sessionRetriever SessionRetriever userManager UserManager accountManager AccountManager bartItemManager BARTItemManager feedbagManager FeedbagManager rateLimitClasses wire.RateLimitClasses timeNow func() time.Time maxConcurrentLoginsPerUser int createAccount state.CreateAccountFunc } // RegisterChatSession adds a user to a chat room. The authCookie param is an // opaque token returned by {{OServiceService.ServiceRequest}} that identifies // the user and chat room. It returns the session object registered in the // ChatSessionRegistry. // This method does not verify that the user and chat room exist because it // implicitly trusts the contents of the token signed by // {{OServiceService.ServiceRequest}}. func (s AuthService) RegisterChatSession(ctx context.Context, authCookie state.ServerCookie, sessCfg func(sess *state.Session)) (*state.SessionInstance, error) { sess, err := s.chatSessionRegistry.AddSession(ctx, authCookie.ChatCookie, authCookie.ScreenName, sessCfg) if err != nil { return nil, fmt.Errorf("AddSession: %w", err) } sess.Session().SetRateClasses(time.Now(), s.rateLimitClasses) return sess, err } func (s AuthService) CrackCookie(authCookie []byte) (state.ServerCookie, error) { c := state.ServerCookie{} buf, err := s.cookieBaker.Crack(authCookie) if err != nil { return c, err } if err := wire.UnmarshalBE(&c, bytes.NewBuffer(buf)); err != nil { return c, err } return c, nil } // RegisterBOSSession adds a new session to the session registry. func (s AuthService) RegisterBOSSession(ctx context.Context, authCookie state.ServerCookie, sessCfg func(sess *state.Session)) (*state.SessionInstance, error) { u, err := s.userManager.User(ctx, authCookie.ScreenName.IdentScreenName()) if err != nil { return nil, fmt.Errorf("failed to retrieve user: %w", err) } if u == nil { return nil, fmt.Errorf("user not found") } ctx, cancel := context.WithTimeout(ctx, time.Second*5) defer cancel() flag := wire.MultiConnFlag(authCookie.MultiConnFlag) doMultiSess := flag == wire.MultiConnFlagsRecentClient cfg := func(sess *state.Session) { sess.SetSignonTime(time.Now()) sess.SetRateClasses(time.Now(), s.rateLimitClasses) sess.SetMemberSince(time.Now()) } sess, err := s.sessionManager.AddSession(ctx, u.DisplayScreenName, doMultiSess, sessCfg, cfg) if err != nil { return nil, fmt.Errorf("AddSession: %w", err) } // set the unconfirmed user info flag if this account is unconfirmed if confirmed, err := s.accountManager.ConfirmStatus(ctx, sess.IdentScreenName()); err != nil { return nil, fmt.Errorf("error setting unconfirmed user flag: %w", err) } else if !confirmed { sess.SetUserInfoFlag(wire.OServiceUserFlagUnconfirmed) } if u.IsBot { sess.SetUserInfoFlag(wire.OServiceUserFlagBot) } sess.SetKerberosAuth(authCookie.KerberosAuth == 1) // set string containing OSCAR client name and version sess.SetClientID(authCookie.ClientID) sess.Session().SetOfflineMsgCount(u.OfflineMsgCount) if _, alreadySet := sess.Session().BuddyIcon(); !alreadySet { bartID, err := s.bartItemManager.BuddyIconMetadata(ctx, sess.IdentScreenName()) if err != nil { return nil, fmt.Errorf("BuddyIconMetadata: %w", err) } if bartID != nil { sess.Session().SetBuddyIcon(*bartID) } } // indicate whether the client supports/wants multiple concurrent sessions sess.SetMultiConnFlag(flag) if u.DisplayScreenName.IsUIN() { sess.SetUserInfoFlag(wire.OServiceUserFlagICQ) uin, err := strconv.Atoi(u.IdentScreenName.String()) if err != nil { return nil, fmt.Errorf("error converting username to UIN: %w", err) } sess.Session().SetUIN(uint32(uin)) } return sess, nil } // RetrieveBOSSession returns a user's existing session instance func (s AuthService) RetrieveBOSSession(ctx context.Context, serverCookie state.ServerCookie) (*state.SessionInstance, error) { u, err := s.userManager.User(ctx, serverCookie.ScreenName.IdentScreenName()) if err != nil { return nil, fmt.Errorf("failed to retrieve user: %w", err) } if u == nil { return nil, fmt.Errorf("user not found") } sess := s.sessionRetriever.RetrieveSession(u.IdentScreenName) if sess == nil { return nil, nil } return sess.Instance(serverCookie.SessionNum), nil } // Signout removes this user's session. func (s AuthService) Signout(ctx context.Context, session *state.Session) { s.sessionManager.RemoveSession(session) } // SignoutChat removes user from chat room and notifies remaining participants // of their departure. func (s AuthService) SignoutChat(ctx context.Context, sess *state.Session) { alertUserLeft(ctx, sess, s.chatMessageRelayer) s.chatSessionRegistry.RemoveSession(sess) } // BUCPChallenge processes a BUCP authentication challenge request. It // retrieves the user's auth key based on the screen name provided in the // request. The client uses the auth key to salt the MD5 password hash provided // in the subsequent login request. If the account is valid, return // SNAC(0x17,0x07), otherwise return SNAC(0x17,0x03). func (s AuthService) BUCPChallenge(ctx context.Context, inBody wire.SNAC_0x17_0x06_BUCPChallengeRequest, newUUID func() uuid.UUID) (wire.SNACMessage, error) { screenName, exists := inBody.String(wire.LoginTLVTagsScreenName) if !exists { s.logger.Debug("BUCPChallenge: screen name TLV not found in request") return wire.SNACMessage{}, errors.New("screen name doesn't exist in tlv") } s.logger.Debug("BUCPChallenge: received challenge request", "screen_name", screenName, "is_uin", state.DisplayScreenName(screenName).IsUIN()) var authKey string user, err := s.userManager.User(ctx, state.NewIdentScreenName(screenName)) if err != nil { s.logger.Error("BUCPChallenge: user lookup failed", "screen_name", screenName, "err", err.Error()) return wire.SNACMessage{}, err } switch { case user != nil: // user lookup succeeded authKey = user.AuthKey s.logger.Debug("BUCPChallenge: user found, returning auth key", "screen_name", screenName, "auth_key_len", len(authKey)) case s.config.DisableAuth: // can't find user, generate stub auth key authKey = newUUID().String() s.logger.Debug("BUCPChallenge: user not found, auth disabled, generating stub auth key", "screen_name", screenName) default: // can't find user, return login error s.logger.Debug("BUCPChallenge: user not found, returning error", "screen_name", screenName, "error_code", wire.LoginErrInvalidUsernameOrPassword) return wire.SNACMessage{ Frame: wire.SNACFrame{ FoodGroup: wire.BUCP, SubGroup: wire.BUCPLoginResponse, }, Body: wire.SNAC_0x17_0x03_BUCPLoginResponse{ TLVRestBlock: wire.TLVRestBlock{ TLVList: []wire.TLV{ wire.NewTLVBE(wire.LoginTLVTagsErrorSubcode, wire.LoginErrInvalidUsernameOrPassword), }, }, }, }, nil } return wire.SNACMessage{ Frame: wire.SNACFrame{ FoodGroup: wire.BUCP, SubGroup: wire.BUCPChallengeResponse, }, Body: wire.SNAC_0x17_0x07_BUCPChallengeResponse{ AuthKey: authKey, }, }, nil } // BUCPLogin processes a BUCP authentication request for AIM v3.5-v5.9. Upon // successful login, a session is created. // If login credentials are invalid and app config DisableAuth is true, a stub // user is created and login continues as normal. DisableAuth allows you to // skip the account creation procedure, which simplifies the login flow during // development. // If login is successful, the SNAC TLV list contains the BOS server address // (wire.LoginTLVTagsReconnectHere) and an authorization cookie // (wire.LoginTLVTagsAuthorizationCookie). Else, an error code is set // (wire.LoginTLVTagsErrorSubcode). func (s AuthService) BUCPLogin(ctx context.Context, inBody wire.SNAC_0x17_0x02_BUCPLoginRequest, advertisedHost string) (wire.SNACMessage, error) { block, err := s.login(ctx, inBody.TLVList, advertisedHost) if err != nil { return wire.SNACMessage{}, err } return wire.SNACMessage{ Frame: wire.SNACFrame{ FoodGroup: wire.BUCP, SubGroup: wire.BUCPLoginResponse, }, Body: wire.SNAC_0x17_0x03_BUCPLoginResponse{ TLVRestBlock: block, }, }, nil } // FLAPLogin processes a FLAP authentication request for AIM v1.0-v3.0. Upon // successful login, a session is created. // If login credentials are invalid and app config DisableAuth is true, a stub // user is created and login continues as normal. DisableAuth allows you to // skip the account creation procedure, which simplifies the login flow during // development. // If login is successful, the SNAC TLV list contains the BOS server address // (wire.LoginTLVTagsReconnectHere) and an authorization cookie // (wire.LoginTLVTagsAuthorizationCookie). Else, an error code is set // (wire.LoginTLVTagsErrorSubcode). func (s AuthService) FLAPLogin(ctx context.Context, inFrame wire.FLAPSignonFrame, advertisedHost string) (wire.TLVRestBlock, error) { return s.login(ctx, inFrame.TLVList, advertisedHost) } // KerberosLogin handles AIM-style Kerberos authentication for AIM 6.0+. // Credit for understanding the SNAC structure and values goes to this mailing // list attachment from 2007: // // https://web.archive.org/web/20100619063015/http://pidgin.im/pipermail/devel/attachments/20070906/e0069ff5/attachment-0001.txt // // Several values in the response are poorly understood but necessary for proper // processing on the client side. func (s AuthService) KerberosLogin(ctx context.Context, inBody wire.SNAC_0x050C_0x0002_KerberosLoginRequest, advertisedHost string) (wire.SNACMessage, error) { b, ok := inBody.TicketRequestMetadata.Bytes(wire.KerberosTLVTicketRequest) if !ok { return wire.SNACMessage{}, fmt.Errorf("ticket request metadata bytes is missing") } var info wire.KerberosLoginRequestTicket if err := wire.UnmarshalBE(&info, bytes.NewReader(b)); err != nil { return wire.SNACMessage{}, fmt.Errorf("ticket request metadata unmarshal: %w", err) } list := wire.TLVList{ wire.NewTLVBE(wire.LoginTLVTagsScreenName, inBody.ClientPrincipal), wire.NewTLVBE(wire.LoginTLVTagsMultiConnFlags, wire.MultiConnFlagsRecentClient), } if info.Version >= 4 { list = append(list, wire.NewTLVBE(wire.LoginTLVTagsRoastedKerberosPassword, info.Password)) } else { list = append(list, wire.NewTLVBE(wire.LoginTLVTagsPlaintextKerberosPassword, info.Password)) } result, err := s.login(ctx, list, advertisedHost) if err != nil { return wire.SNACMessage{}, fmt.Errorf("login: %w", err) } cookie, loginOK := result.Bytes(wire.LoginTLVTagsAuthorizationCookie) if !loginOK { return wire.SNACMessage{ Frame: wire.SNACFrame{ FoodGroup: wire.Kerberos, SubGroup: wire.KerberosKerberosLoginErrResponse, }, Body: wire.SNAC_0x050C_0x0004_KerberosLoginErrResponse{ KerbRequestID: inBody.RequestID, ScreenName: inBody.ClientPrincipal, ErrCode: wire.KerberosErrAuthFailure, Message: "Auth failure", }, }, nil } return wire.SNACMessage{ Frame: wire.SNACFrame{ FoodGroup: wire.Kerberos, SubGroup: wire.KerberosLoginSuccessResponse, }, Body: wire.SNAC_0x050C_0x0003_KerberosLoginSuccessResponse{ RequestID: inBody.RequestID, Epoch: uint32(s.timeNow().Unix()), ClientPrincipal: inBody.ClientPrincipal, ClientRealm: "AOL", Tickets: []wire.KerberosTicket{ { PVNO: 5, EncTicket: []byte{}, TicketRealm: "AOL", ServicePrincipal: "im/boss", ClientRealm: "AOL", ClientPrincipal: inBody.ClientPrincipal, AuthTime: uint32(s.timeNow().Unix()), StartTime: uint32(s.timeNow().Unix()), EndTime: uint32(s.timeNow().Add(24 * time.Hour).Unix()), Unknown4: 1610612736, Unknown5: 1073741824, ConnectionMetadata: wire.TLVBlock{ TLVList: wire.TLVList{ wire.NewTLVBE(wire.KerberosTLVBOSServerInfo, wire.KerberosBOSServerInfo{ Unknown: 1, ConnectionInfo: wire.TLVBlock{ TLVList: wire.TLVList{ wire.NewTLVBE(wire.KerberosTLVHostname, advertisedHost), wire.NewTLVBE(wire.KerberosTLVCookie, cookie), wire.NewTLVBE(wire.KerberosTLVConnSettings, wire.KerberosConnUseSSL), }, }, }), }, }, }, }, }, }, nil } // loginProperties represents the properties sent by the client at login. type loginProperties struct { clientID string isBUCPAuth bool isFLAPAuth bool isFLAPJavaAuth bool isKerberosPlaintextAuth bool isKerberosRoastedAuth bool isPlaintextAuth bool isTOCAuth bool multiConnFlag uint8 passwordHash []byte plaintextPassword []byte roastedPass []byte screenName state.DisplayScreenName } // fromTLV creates an instance of loginProperties from a TLV list. func (l *loginProperties) fromTLV(list wire.TLVList) error { // extract screen name if screenName, found := list.String(wire.LoginTLVTagsScreenName); found { l.screenName = state.DisplayScreenName(screenName) } else { return errors.New("screen name doesn't exist in tlv") } // extract client name and version if clientID, found := list.String(wire.LoginTLVTagsClientIdentity); found { l.clientID = clientID } // get the password from the appropriate TLV. older clients have a // roasted password, newer clients have a hashed password. ICQ may omit // the password TLV when logging in without saved password. switch { case list.HasTag(wire.LoginTLVTagsPasswordHash): // extract password hash for BUCP login l.passwordHash, _ = list.Bytes(wire.LoginTLVTagsPasswordHash) l.isBUCPAuth = true case list.HasTag(wire.LoginTLVTagsRoastedPassword): // extract roasted password for FLAP login l.roastedPass, _ = list.Bytes(wire.LoginTLVTagsRoastedPassword) if strings.HasPrefix(l.clientID, "AOL Instant Messenger (TM) version") && strings.Contains(l.clientID, "for Java") { l.isFLAPJavaAuth = true } else { l.isFLAPAuth = true } case list.HasTag(wire.LoginTLVTagsRoastedTOCPassword): // extract roasted password for TOC FLAP login l.roastedPass, _ = list.Bytes(wire.LoginTLVTagsRoastedTOCPassword) l.isTOCAuth = true case list.HasTag(wire.LoginTLVTagsPlaintextKerberosPassword): l.plaintextPassword, _ = list.Bytes(wire.LoginTLVTagsPlaintextKerberosPassword) l.isKerberosPlaintextAuth = true case list.HasTag(wire.LoginTLVTagsPlaintextPassword): l.plaintextPassword, _ = list.Bytes(wire.LoginTLVTagsPlaintextPassword) l.isPlaintextAuth = true case list.HasTag(wire.LoginTLVTagsRoastedKerberosPassword): l.roastedPass, _ = list.Bytes(wire.LoginTLVTagsRoastedKerberosPassword) l.isKerberosRoastedAuth = true default: l.isFLAPAuth = true } // does the client support multiple concurrent sessions? if multiConnFlags, found := list.Uint8(wire.LoginTLVTagsMultiConnFlags); found { l.multiConnFlag = multiConnFlags } return nil } // login validates a user's credentials and creates their session. it returns // metadata used in both BUCP and FLAP authentication responses. func (s AuthService) login(ctx context.Context, tlv wire.TLVList, advertisedHost string) (wire.TLVRestBlock, error) { props := loginProperties{} if err := props.fromTLV(tlv); err != nil { s.logger.Debug("login: failed to parse TLVs", "err", err.Error()) return wire.TLVRestBlock{}, err } s.logger.Debug("login: parsed login properties", "screen_name", props.screenName, "client_id", props.clientID, "is_bucp", props.isBUCPAuth, "is_flap", props.isFLAPAuth, "is_flap_java", props.isFLAPJavaAuth, "is_toc", props.isTOCAuth, "is_kerberos_plaintext", props.isKerberosPlaintextAuth, "is_kerberos_roasted", props.isKerberosRoastedAuth, "is_plaintext", props.isPlaintextAuth, "password_hash_len", len(props.passwordHash), "roasted_pass_len", len(props.roastedPass)) user, err := s.userManager.User(ctx, props.screenName.IdentScreenName()) if err != nil { s.logger.Error("login: user lookup failed", "screen_name", props.screenName, "err", err.Error()) return wire.TLVRestBlock{}, err } if user == nil { s.logger.Debug("login: user not found", "screen_name", props.screenName) // user not found if s.config.DisableAuth { // auth disabled, create the user s.logger.Debug("login: auth disabled, creating user", "screen_name", props.screenName) return s.createUser(ctx, props, advertisedHost) } // auth enabled, return separate login errors for ICQ and AIM loginErr := wire.LoginErrInvalidUsernameOrPassword if props.screenName.IsUIN() { loginErr = wire.LoginErrICQUserErr } s.logger.Debug("login: returning user not found error", "screen_name", props.screenName, "error_code", loginErr) return loginFailureResponse(props, loginErr), nil } s.logger.Debug("login: user found", "screen_name", props.screenName, "is_icq", user.IsICQ) // check if suspended status should prevent login if user.SuspendedStatus > 0x0 { s.logger.Debug("login: user suspended", "screen_name", props.screenName, "suspended_status", user.SuspendedStatus) return loginFailureResponse(props, user.SuspendedStatus), nil } if s.config.DisableAuth { // user exists, but don't validate s.logger.Debug("login: auth disabled, skipping password validation", "screen_name", props.screenName) return s.loginSuccessResponse(ctx, props, advertisedHost) } var loginOK bool var authMethod string switch { case props.isBUCPAuth: authMethod = "BUCP" loginOK = user.ValidateHash(props.passwordHash) case props.isFLAPAuth: authMethod = "FLAP" loginOK = user.ValidateRoastedPass(props.roastedPass) case props.isFLAPJavaAuth: authMethod = "FLAP_Java" loginOK = user.ValidateRoastedJavaPass(props.roastedPass) case props.isTOCAuth: authMethod = "TOC" loginOK = user.ValidateRoastedTOCPass(props.roastedPass) case props.isKerberosPlaintextAuth: authMethod = "Kerberos_Plaintext" loginOK = user.ValidatePlaintextPass(props.plaintextPassword) case props.isKerberosRoastedAuth: authMethod = "Kerberos_Roasted" loginOK = user.ValidateRoastedKerberosPass(props.roastedPass) case props.isPlaintextAuth: authMethod = "Plaintext" loginOK = user.ValidatePlaintextPass(props.plaintextPassword) } s.logger.Debug("login: password validation result", "screen_name", props.screenName, "auth_method", authMethod, "login_ok", loginOK) if !loginOK { s.logger.Debug("login: password validation failed", "screen_name", props.screenName, "auth_method", authMethod) return loginFailureResponse(props, wire.LoginErrInvalidPassword), nil } // limit concurrent logins per user if props.multiConnFlag == uint8(wire.MultiConnFlagsRecentClient) { sess := s.sessionRetriever.RetrieveSession(props.screenName.IdentScreenName()) if sess != nil && sess.InstanceCount() >= s.maxConcurrentLoginsPerUser { s.logger.Debug("login: too many concurrent sessions", "screen_name", props.screenName, "instance_count", sess.InstanceCount()) return loginFailureResponse(props, wire.LoginErrRateLimitExceeded), nil } } s.logger.Debug("login: login successful", "screen_name", props.screenName) return s.loginSuccessResponse(ctx, props, advertisedHost) } func (s AuthService) createUser(ctx context.Context, props loginProperties, advertisedHost string) (wire.TLVRestBlock, error) { err := s.createAccount(ctx, props.screenName, "welcome1") if err != nil { switch { case errors.Is(err, state.ErrAIMHandleInvalidFormat) || errors.Is(err, state.ErrAIMHandleLength): return loginFailureResponse(props, wire.LoginErrInvalidUsernameOrPassword), nil case errors.Is(err, state.ErrICQUINInvalidFormat): return loginFailureResponse(props, wire.LoginErrICQUserErr), nil default: return wire.TLVRestBlock{}, err } } return s.loginSuccessResponse(ctx, props, advertisedHost) } func (s AuthService) loginSuccessResponse(ctx context.Context, props loginProperties, advertisedHost string) (wire.TLVRestBlock, error) { loginCookie := state.ServerCookie{ Service: wire.BOS, ScreenName: props.screenName, ClientID: props.clientID, MultiConnFlag: props.multiConnFlag, } if props.isKerberosPlaintextAuth || props.isKerberosRoastedAuth { loginCookie.KerberosAuth = 1 } buf := &bytes.Buffer{} if err := wire.MarshalBE(loginCookie, buf); err != nil { return wire.TLVRestBlock{}, err } cookie, err := s.cookieBaker.Issue(buf.Bytes()) if err != nil { return wire.TLVRestBlock{}, fmt.Errorf("failed to issue auth cookie: %w", err) } reconnectHost := advertisedHost sslState := wire.OServiceServiceResponseSSLStateNotUsed s.logger.Debug("loginSuccessResponse: returning login response", "screen_name", props.screenName, "reconnect_host", reconnectHost, "ssl_state", sslState) loginTLVTags := wire.TLVList{ wire.NewTLVBE(wire.LoginTLVTagsScreenName, props.screenName), wire.NewTLVBE(wire.LoginTLVTagsReconnectHere, reconnectHost), wire.NewTLVBE(wire.LoginTLVTagsAuthorizationCookie, cookie), wire.NewTLVBE(wire.OServiceTLVTagsSSLState, sslState), } if err := s.addLinkedAccountsTLV(ctx, props.screenName, &loginTLVTags); err != nil { return wire.TLVRestBlock{}, err } return wire.TLVRestBlock{TLVList: loginTLVTags}, nil } // addLinkedAccountsTLV builds the linked accounts XML and appends the // corresponding TLV to tlvs. If linkedNames is empty, tlvs is not modified. func (s AuthService) addLinkedAccountsTLV(ctx context.Context, screenName state.DisplayScreenName, tlvs *wire.TLVList) error { items, err := s.feedbagManager.Feedbag(ctx, screenName.IdentScreenName()) if err != nil { return fmt.Errorf("failed to get linked accounts: %w", err) } linkedNames := state.NewFeedbagList(items, nil).LinkedScreenNames() if len(linkedNames) == 0 { return nil } acctXML, err := buildLinkedAccountsXML(screenName.IdentScreenName(), linkedNames) if err != nil { return fmt.Errorf("failed to build linked accounts xml: %w", err) } *tlvs = append(*tlvs, wire.NewTLVBE(wire.OServiceTLVTagsLinkedAccounts, acctXML)) return nil } func loginFailureResponse(props loginProperties, errCode uint16) wire.TLVRestBlock { return wire.TLVRestBlock{ TLVList: []wire.TLV{ wire.NewTLVBE(wire.LoginTLVTagsScreenName, props.screenName), wire.NewTLVBE(wire.LoginTLVTagsErrorSubcode, errCode), }, } } // buildLinkedAccountsXML will return the XML doc expected for wire.OServiceTLVTagsLinkedAccounts // Example: // // // PrimaryName // LinkedName1 // LinkedName2 // LinkedName3 // LinkedName4 // // func buildLinkedAccountsXML(screenName state.IdentScreenName, linkedNames []state.IdentScreenName) (string, error) { // ResRec represents the element with its attributes and nested tag type ResRec struct { Type string `xml:"TYPE,attr"` ID string `xml:"ID,attr"` Name string `xml:"n"` } // Set represents the root element type Set struct { XMLName xml.Name `xml:"SET"` SetID string `xml:"SETID,attr"` Records []ResRec `xml:"RESREC"` } data := Set{ SetID: "1", Records: []ResRec{ {Type: "PRIMARY-ACCOUNT", ID: "1", Name: screenName.String()}, }, } for i, name := range linkedNames { newRec := ResRec{ Type: "LINKED-ACCOUNT", // Incrementing ID starting from 2 (since Primary is 1) ID: strconv.Itoa(i + 2), Name: name.String(), } data.Records = append(data.Records, newRec) } output, err := xml.Marshal(data) if err != nil { return "", err } return string(output), nil }