Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,20 @@ cd data

## Login

Email/password login is always available. Set `network.qr_login: true` in
`config.yaml` and restart to make QR Code the first login option. Scan with
the LINE mobile app, then enter the displayed PIN on your phone. Set the setting
back to `false` to disable new QR login attempts.

Beeper Services controls client rollout with
`bridge:line:login:dev.highest.matrix.line.qr_login`, defaulting to `false`.
Distribute that flag before enabling QR login in the cloud bridge config.

Use `logging.min_level: warn` when enabling QR login: provisioning info/debug
logs include QR URLs and verification codes. Passwordless accounts need another
QR scan if token refresh fails. QR login currently requires a Letter Sealing
keychain; accounts without one fail before a login is stored.

### Via Beeper Desktop Settings

1. Open Beeper Desktop Settings
Expand Down
6 changes: 6 additions & 0 deletions pkg/connector/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import (
var (
errLineSessionInvalidated = errors.New("LINE session invalidated by another client")
errLineClientSuperseded = errors.New("LINE client was superseded")
errLineQRLoginRequired = errors.New("LINE requires a new QR scan. Reconnect in Beeper to continue")
)

const lineMissingE2EEKeyMessage = "LINE encryption keys are unavailable. Reconnect LINE in Beeper to restore message decryption."
Expand Down Expand Up @@ -702,6 +703,11 @@ func (lc *LineClient) tryLogin(ctx context.Context) error {
}

if email == "" || password == "" {
if lc.UserLogin.Bridge != nil {
if network, ok := lc.UserLogin.Bridge.Network.(*LineConnector); ok && network.Config.QRLogin {
return errLineQRLoginRequired
}
}
return fmt.Errorf("no stored credentials available for re-login")
}

Expand Down
53 changes: 44 additions & 9 deletions pkg/connector/connector.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ const (
)

type LineConnector struct {
Config Config
br *bridgev2.Bridge
loginFinalizeMu sync.Mutex
directMedia atomic.Bool
Expand Down Expand Up @@ -96,7 +97,17 @@ func (lc *LineConnector) GetName() bridgev2.BridgeName {
}

func (lc *LineConnector) GetConfig() (example string, data any, upgrader configupgrade.Upgrader) {
return "", nil, nil
const base = "qr_login: false\n"
return base, &lc.Config, &configupgrade.StructUpgrader{
Base: base,
SimpleUpgrader: func(helper configupgrade.Helper) {
helper.Copy(configupgrade.Bool, "qr_login")
},
}
}

type Config struct {
QRLogin bool `yaml:"qr_login"`
}

func (lc *LineConnector) GetDBMetaTypes() database.MetaTypes {
Expand Down Expand Up @@ -154,20 +165,28 @@ func (lc *LineConnector) LoadUserLogin(ctx context.Context, login *bridgev2.User
}

const LoginFlowIDEmail = "dev.highest.matrix.line.email_login"
const LoginFlowIDQR = "dev.highest.matrix.line.qr_login"

func (lc *LineConnector) GetLoginFlows() []bridgev2.LoginFlow {
return []bridgev2.LoginFlow{{
flows := []bridgev2.LoginFlow{{
Name: "Login",
Description: "Login with your LINE Email and Password",
ID: LoginFlowIDEmail,
}}
if lc.Config.QRLogin {
flows = append([]bridgev2.LoginFlow{{Name: "QR Code", Description: "Scan a QR code with the LINE mobile app", ID: LoginFlowIDQR}}, flows...)
}
return flows
}

func (lc *LineConnector) CreateLogin(ctx context.Context, user *bridgev2.User, flowID string) (bridgev2.LoginProcess, error) {
if flowID == LoginFlowIDQR && lc.Config.QRLogin {
return &LineQRLogin{login: &LineEmailLogin{User: user, finalizeMu: &lc.loginFinalizeMu}}, nil
}
if flowID != LoginFlowIDEmail {
return nil, bridgev2.ErrInvalidLoginFlowID
}
return &LineEmailLogin{User: user, finalizeMu: &lc.loginFinalizeMu}, nil
return &LineEmailLogin{User: user, finalizeMu: &lc.loginFinalizeMu, qrEnabled: lc.Config.QRLogin}, nil
}

type LineEmailLogin struct {
Expand All @@ -181,6 +200,8 @@ type LineEmailLogin struct {

ExistingMetadata *UserLoginMetadata
ExistingLogin *bridgev2.UserLogin
qrEnabled bool
qrLogin *LineQRLogin

pollResult chan *line.LoginResult
pollErr chan error
Expand Down Expand Up @@ -231,6 +252,14 @@ func (ll *LineEmailLogin) StartWithOverride(ctx context.Context, override *bridg
ll.ExistingLogin = override

if ll.Email == "" || ll.Password == "" {
if ll.qrEnabled {
ll.Email, ll.Password = "", ""
ll.mu.Lock()
ll.qrLogin = &LineQRLogin{login: ll}
qrLogin := ll.qrLogin
ll.mu.Unlock()
return qrLogin.StartWithOverride(ctx, override)
}
return ll.loginErrorStep("No stored LINE credentials are available. Please enter your LINE email and password to reconnect."), nil
}
if meta.ForceFullE2EELogin || len(meta.ExportedKeyMap) == 0 {
Expand Down Expand Up @@ -504,6 +533,10 @@ func (ll *LineEmailLogin) loginCredentials(ctx context.Context, certificate stri

func (ll *LineEmailLogin) Wait(ctx context.Context) (*bridgev2.LoginStep, error) {
ll.mu.Lock()
if qrLogin := ll.qrLogin; qrLogin != nil {
ll.mu.Unlock()
return qrLogin.Wait(ctx)
}
verifier, awaitingPIN := ll.Verifier, ll.AwaitingPIN
resultCh, errCh := ll.pollResult, ll.pollErr
var done <-chan struct{}
Expand Down Expand Up @@ -697,17 +730,19 @@ func (ll *LineEmailLogin) finishLogin(ctx context.Context, res *line.LoginResult
displayName = "LINE User"
}

certificate := res.Certificate
if certificate == "" {
certificate = ll.Certificate
}
mid := profile.Mid
if mid == "" || (res.Mid != "" && res.Mid != mid) {
return nil, errors.New("login result does not match verified LINE account")
}

meta := &UserLoginMetadata{AccessToken: token, RefreshToken: refreshToken, Email: ll.Email, Password: ll.Password, Certificate: certificate, Mid: mid}
sameAccount := ll.ExistingMetadata != nil && ll.ExistingLogin != nil && ll.ExistingLogin.UserLogin != nil && mid == string(ll.ExistingLogin.ID) && mid == ll.ExistingMetadata.Mid
certificate := res.Certificate
if certificate == "" && (ll.ExistingMetadata == nil || sameAccount) {
certificate = ll.Certificate
}
meta := &UserLoginMetadata{AccessToken: token, RefreshToken: refreshToken, Email: ll.Email, Password: ll.Password, Certificate: certificate, Mid: mid}
if sameAccount && meta.Email == "" && meta.Password == "" {
meta.Email, meta.Password = ll.ExistingMetadata.Email, ll.ExistingMetadata.Password
}

loginManager, err := ll.fetchLoginKeys(res, meta, client)
if err != nil {
Expand Down
155 changes: 155 additions & 0 deletions pkg/connector/login_qr.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
package connector

import (
"context"
"fmt"
"time"

"maunium.net/go/mautrix/bridgev2"

"github.com/highesttt/matrix-line-messenger/pkg/line"
)

type LineQRLogin struct {
login *LineEmailLogin
client *line.Client
session string
qr *line.QRCodeResponse
poll chan error
pin bool
}

var _ bridgev2.LoginProcessDisplayAndWait = (*LineQRLogin)(nil)
var _ bridgev2.LoginProcessWithOverride = (*LineQRLogin)(nil)

func (lq *LineQRLogin) StartWithOverride(ctx context.Context, override *bridgev2.UserLogin) (*bridgev2.LoginStep, error) {
meta, ok := override.Metadata.(*UserLoginMetadata)
if !ok {
return nil, fmt.Errorf("existing LINE login metadata has unexpected type %T", override.Metadata)
}
lq.login.ExistingLogin, lq.login.ExistingMetadata = override, meta
lq.login.Certificate = meta.Certificate
if meta.ForceFullE2EELogin || len(meta.ExportedKeyMap) == 0 {
lq.login.Certificate = ""
}
return lq.Start(ctx)
}

func (lq *LineQRLogin) Start(ctx context.Context) (*bridgev2.LoginStep, error) {
ll := lq.login
ll.mu.Lock()
if ll.canceled || ll.attemptCtx != nil {
ll.mu.Unlock()
return nil, fmt.Errorf("QR login is already started or canceled")
}
ll.attemptCtx, ll.attemptCancel = context.WithTimeout(context.WithoutCancel(ctx), 10*time.Minute)
processCtx := ll.attemptCtx
ll.mu.Unlock()
stop := context.AfterFunc(ctx, ll.attemptCancel)
defer stop()
client := newLineAPIClient("")
session, qr, err := client.StartQRLogin(processCtx)
ll.mu.Lock()
ll.attempt = client.LoginAttempt
canceled := ll.canceled || ctx.Err() != nil || processCtx.Err() != nil
ll.mu.Unlock()
if err != nil || canceled {
lq.Cancel()
if canceled {
return nil, context.Canceled
}
return nil, err
}
lq.client, lq.session, lq.qr = client, session, qr
timeout := time.Duration(qr.LongPollingIntervalSeconds) * time.Second
if timeout <= 0 {
timeout = 150 * time.Second
}
lq.startPoll(func(ctx context.Context, session string) error {
return client.CheckQRCodeVerifiedContext(ctx, session, timeout)
}, true)
return &bridgev2.LoginStep{
Type: bridgev2.LoginStepTypeDisplayAndWait, StepID: "dev.highest.matrix.line.qr",
Instructions: "Scan this QR code with the LINE mobile app.",
DisplayAndWaitParams: &bridgev2.LoginDisplayAndWaitParams{Type: bridgev2.LoginDisplayTypeQR, Data: qr.CallbackURL},
}, nil
}

func (lq *LineQRLogin) startPoll(check func(context.Context, string) error, retryExpired bool) {
result := make(chan error, 1)
lq.poll = result
ctx, session, qr := lq.login.attemptCtx, lq.session, lq.qr
go func() {
count := max(1, min(qr.LongPollingMaxCount, 10))
for attempt := 0; ; attempt++ {
err := check(ctx, session)
if err == nil || ctx.Err() != nil || attempt+1 >= count || !retryExpired || !line.IsQRLoginPollExpired(err) {
result <- err
return
}
timer := time.NewTimer(min(time.Second<<attempt, 30*time.Second))
select {
case <-ctx.Done():
timer.Stop()
result <- ctx.Err()
return
case <-timer.C:
}
}
}()
}

func (lq *LineQRLogin) Wait(ctx context.Context) (*bridgev2.LoginStep, error) {
Comment thread
indent[bot] marked this conversation as resolved.
if lq.poll == nil {
return nil, fmt.Errorf("QR login has not started")
}
select {
case err := <-lq.poll:
if err != nil {
lq.Cancel()
return nil, err
}
case <-lq.login.attemptCtx.Done():
lq.Cancel()
return nil, lq.login.attemptCtx.Err()
case <-ctx.Done():
lq.Cancel()
return nil, ctx.Err()
}
if !lq.pin {
verified, err := lq.client.VerifyQRCertificate(ctx, lq.session, lq.login.Certificate)
if err != nil {
lq.Cancel()
return nil, err
}
if verified {
return lq.finish(ctx)
}
pin, err := lq.client.CreatePinCode(ctx, lq.session)
if err != nil {
lq.Cancel()
return nil, err
}
lq.pin = true
lq.startPoll(lq.client.CheckPinCodeVerifiedContext, false)
return &bridgev2.LoginStep{
Type: bridgev2.LoginStepTypeDisplayAndWait, StepID: "dev.highest.matrix.line.qr_pin",
Instructions: "Enter the displayed PIN in the LINE mobile app.",
DisplayAndWaitParams: &bridgev2.LoginDisplayAndWaitParams{Type: bridgev2.LoginDisplayTypeCode, Data: pin},
}, nil
}
return lq.finish(ctx)
}

func (lq *LineQRLogin) finish(ctx context.Context) (*bridgev2.LoginStep, error) {
defer lq.Cancel()
res, err := lq.client.QRCodeLoginV2(ctx, lq.session)
if err != nil {
return nil, err
}
return lq.login.finishLogin(ctx, res)
}

func (lq *LineQRLogin) Cancel() {
lq.login.Cancel()
}
Loading
Loading